diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,11 +3,25 @@
 All notable changes to this project will be documented in this file.
 This project adheres to [Semantic Versioning](http://semver.org/).
 
-## Unreleased
-
-### Added
+## [9.0.1] - 2022-06-03
 
 ### Fixed
+
+ - #2165, Fix json/jsonb columns should not have type in OpenAPI spec - @clrnd
+ - #2020, Execute deferred constraint triggers when using `Prefer: tx=rollback` - @wolfgangwalther
+ - #2077, Fix `is` not working with upper or mixed case values like `NULL, TrUe, FaLsE` - @steve-chavez
+ - #2024, Fix schema cache loading when views with XMLTABLE and DEFAULT are present - @wolfgangwalther
+ - #1724, Fix wrong CORS header Authentication -> Authorization - @wolfgangwalther
+ - #2120, Fix reading database configuration properly when `=` is present in value - @wolfgangwalther
+ - #2135, Remove trigger functions from schema cache and OpenAPI output, because they can't be called directly anyway. - @wolfgangwalther
+ - #2101, Remove aggregates, procedures and window functions from the schema cache and OpenAPI output. - @wolfgangwalther
+ - #2153, Fix --dump-schema running with a wrong PG version. - @wolfgangwalther
+ - #2042, Keep working when EMFILE(Too many open files) is reached. - @steve-chavez
+ - #2147, Ignore `Content-Type` headers for `GET` requests when calling RPCs. - @laurenceisla
+   + Previously, `GET` without parameters, but with `Content-Type: text/plain` or `Content-Type: application/octet-stream` would fail with `404 Not Found`, even if a function without arguments was available.
+ - #2239, Fix misleading disambiguation error where the content of the `relationship` key looks like valid syntax - @laurenceisla
+ - #2294, Disable parallel GC for better performance on higher core CPUs - @steve-chavez
+ - #1076, Fix using CPU while idle - @steve-chavez
 
 ## [9.0.0] - 2021-11-25
 
diff --git a/postgrest.cabal b/postgrest.cabal
--- a/postgrest.cabal
+++ b/postgrest.cabal
@@ -1,8 +1,8 @@
 name:               postgrest
-version:            9.0.0
+version:            9.0.1
 synopsis:           REST API for any Postgres database
 description:        Reads the schema of a PostgreSQL database and creates RESTful routes
-                    for the tables and views, supporting all HTTP verbs that security
+                    for tables, views, and functions, supporting all HTTP verbs that security
                     permits.
 license:            MIT
 license-file:       LICENSE
@@ -44,6 +44,7 @@
                       PostgREST.Config.PgVersion
                       PostgREST.Config.Proxy
                       PostgREST.ContentType
+                      PostgREST.Cors
                       PostgREST.DbStructure
                       PostgREST.DbStructure.Identifiers
                       PostgREST.DbStructure.Proc
@@ -51,6 +52,7 @@
                       PostgREST.DbStructure.Table
                       PostgREST.Error
                       PostgREST.GucHeader
+                      PostgREST.Logger
                       PostgREST.Middleware
                       PostgREST.OpenAPI
                       PostgREST.Query.QueryBuilder
@@ -69,7 +71,6 @@
                     , HTTP                      >= 4000.3.7 && < 4000.4
                     , Ranged-sets               >= 0.3 && < 0.5
                     , aeson                     >= 1.4.7 && < 1.6
-                    , ansi-wl-pprint            >= 0.6.7 && < 0.7
                     , auto-update               >= 0.1.4 && < 0.2
                     , base64-bytestring         >= 1 && < 1.3
                     , bytestring                >= 0.10.8 && < 0.11
@@ -77,11 +78,9 @@
                     , cassava                   >= 0.4.5 && < 0.6
                     , configurator-pg           >= 0.2 && < 0.3
                     , containers                >= 0.5.7 && < 0.7
-                    , contravariant             >= 1.4 && < 1.6
                     , contravariant-extras      >= 0.3.3 && < 0.4
                     , cookie                    >= 0.4.2 && < 0.5
                     , either                    >= 4.4.1 && < 5.1
-                    , fast-logger               >= 2.4.5
                     , gitrev                    >= 1.2 && < 1.4
                     , hasql                     >= 1.4 && < 1.5
                     , hasql-dynamic-statements  == 0.3.1
@@ -99,7 +98,7 @@
                     , network-uri               >= 2.6.1 && < 2.8
                     , optparse-applicative      >= 0.13 && < 0.17
                     , parsec                    >= 3.1.11 && < 3.2
-                    , protolude                 >= 0.3 && < 0.4
+                    , protolude                 >= 0.3.1 && < 0.4
                     , regex-tdfa                >= 1.2.2 && < 1.4
                     , retry                     >= 0.7.4 && < 0.10
                     , scientific                >= 0.3.4 && < 0.4
@@ -110,10 +109,8 @@
                     , vector                    >= 0.11 && < 0.13
                     , wai                       >= 3.2.1 && < 3.3
                     , wai-cors                  >= 0.2.5 && < 0.3
-                    , wai-extra                 >= 3.0.19 && < 3.2
-                    , wai-logger                >= 2.3.2
-                    , wai-middleware-static     >= 0.8.1 && < 0.10
-                    , warp                      >= 3.2.12 && < 3.4
+                    , wai-extra                 >= 3.1.8 && < 3.2
+                    , warp                      >= 3.3.19 && < 3.4
                       -- -fno-spec-constr may help keep compile time memory use in check,
                       --   see https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304
                       -- -optP-Wno-nonportable-include-path
@@ -123,9 +120,9 @@
                       -fno-spec-constr -optP-Wno-nonportable-include-path
 
   if flag(dev)
-    ghc-options: -O0
+    ghc-options: -O0 -fwrite-ide-info
     if flag(hpc)
-      ghc-options: -hpcdir .hpc
+      ghc-options: --enable-coverage -hpcdir .hpc
   else
     ghc-options: -O2
 
@@ -146,15 +143,17 @@
   build-depends:      base                >= 4.9 && < 4.16
                     , containers          >= 0.5.7 && < 0.7
                     , postgrest
-                    , protolude           >= 0.3 && < 0.4
-  ghc-options:        -threaded -rtsopts "-with-rtsopts=-N -I2"
+                    , protolude           >= 0.3.1 && < 0.4
+  ghc-options:        -threaded -rtsopts "-with-rtsopts=-N -I0 -qg"
                       -O2 -Wall -fwarn-identities
                       -fno-spec-constr -optP-Wno-nonportable-include-path
 
   if flag(dev)
-    ghc-options: -O0
+    ghc-options: -O0 -fwrite-ide-info
+                 -- https://github.com/PostgREST/postgrest/issues/387
+                 -with-rtsopts=-K1K
     if flag(hpc)
-      ghc-options: -hpcdir .hpc
+      ghc-options: --enable-coverage -hpcdir .hpc
   else
     ghc-options: -O2
 
@@ -164,7 +163,7 @@
   default-extensions: OverloadedStrings
                       QuasiQuotes
                       NoImplicitPrelude
-  hs-source-dirs:     test
+  hs-source-dirs:     test/spec
   main-is:            Main.hs
   other-modules:      Feature.AndOrParamsSpec
                       Feature.AsymmetricJwtSpec
@@ -211,10 +210,7 @@
                     , base64-bytestring >= 1 && < 1.3
                     , bytestring        >= 0.10.8 && < 0.11
                     , case-insensitive  >= 1.2 && < 1.3
-                    , cassava           >= 0.4.5 && < 0.6
                     , containers        >= 0.5.7 && < 0.7
-                    , contravariant     >= 1.4 && < 1.6
-                    , hasql             >= 1.4 && < 1.5
                     , hasql-pool        >= 0.5 && < 0.6
                     , hasql-transaction >= 1.0.1 && < 1.1
                     , heredoc           >= 0.2 && < 0.3
@@ -227,72 +223,67 @@
                     , monad-control     >= 1.0.1 && < 1.1
                     , postgrest
                     , process           >= 1.4.2 && < 1.7
-                    , protolude         >= 0.3 && < 0.4
+                    , protolude         >= 0.3.1 && < 0.4
                     , regex-tdfa        >= 1.2.2 && < 1.4
                     , text              >= 1.2.2 && < 1.3
-                    , time              >= 1.6 && < 1.11
                     , transformers-base >= 0.4.4 && < 0.5
                     , wai               >= 3.2.1 && < 3.3
                     , wai-extra         >= 3.0.19 && < 3.2
   ghc-options:        -O0 -Wall -fwarn-identities
                       -fno-spec-constr -optP-Wno-nonportable-include-path
                       -fno-warn-missing-signatures
+                      -fwrite-ide-info
+                      -- https://github.com/PostgREST/postgrest/issues/387
+                      -with-rtsopts=-K33K
 
 test-suite querycost
-  type:                exitcode-stdio-1.0
-  default-language:    Haskell2010
-  default-extensions:  OverloadedStrings
-                       QuasiQuotes
-                       NoImplicitPrelude
-  hs-source-dirs:      test
-  main-is:             QueryCost.hs
-  other-modules:       SpecHelper
-  build-depends:       base              >= 4.9 && < 4.16
-                     , aeson             >= 1.4.7 && < 1.6
-                     , aeson-qq          >= 0.8.1 && < 0.9
-                     , async             >= 2.1.1 && < 2.3
-                     , auto-update       >= 0.1.4 && < 0.2
-                     , base64-bytestring >= 1 && < 1.3
-                     , bytestring        >= 0.10.8 && < 0.11
-                     , case-insensitive  >= 1.2 && < 1.3
-                     , cassava           >= 0.4.5 && < 0.6
-                     , containers        >= 0.5.7 && < 0.7
-                     , contravariant     >= 1.4 && < 1.6
-                     , hasql             >= 1.4 && < 1.5
-                     , hasql-dynamic-statements  == 0.3.1
-                     , hasql-pool        >= 0.5 && < 0.6
-                     , hasql-transaction >= 1.0.1 && < 1.1
-                     , heredoc           >= 0.2 && < 0.3
-                     , hspec             >= 2.3 && < 2.9
-                     , hspec-wai         >= 0.10 && < 0.12
-                     , hspec-wai-json    >= 0.10 && < 0.12
-                     , http-types        >= 0.12.3 && < 0.13
-                     , lens              >= 4.14 && < 5.1
-                     , lens-aeson        >= 1.0.1 && < 1.2
-                     , monad-control     >= 1.0.1 && < 1.1
-                     , postgrest
-                     , process           >= 1.4.2 && < 1.7
-                     , protolude         >= 0.3 && < 0.4
-                     , regex-tdfa        >= 1.2.2 && < 1.4
-                     , text              >= 1.2.2 && < 1.3
-                     , time              >= 1.6 && < 1.11
-                     , transformers-base >= 0.4.4 && < 0.5
-                     , wai               >= 3.2.1 && < 3.3
-                     , wai-extra         >= 3.0.19 && < 3.2
-  ghc-options:         -O0 -Wall -fwarn-identities
-                       -fno-spec-constr -optP-Wno-nonportable-include-path
+  type:               exitcode-stdio-1.0
+  default-language:   Haskell2010
+  default-extensions: OverloadedStrings
+                      QuasiQuotes
+                      NoImplicitPrelude
+  hs-source-dirs:     test/spec
+  main-is:            QueryCost.hs
+  other-modules:      SpecHelper
+  build-depends:      base              >= 4.9 && < 4.16
+                    , aeson             >= 1.4.7 && < 1.6
+                    , base64-bytestring >= 1 && < 1.3
+                    , bytestring        >= 0.10.8 && < 0.11
+                    , case-insensitive  >= 1.2 && < 1.3
+                    , containers        >= 0.5.7 && < 0.7
+                    , contravariant     >= 1.4 && < 1.6
+                    , hasql             >= 1.4 && < 1.5
+                    , hasql-dynamic-statements  == 0.3.1
+                    , hasql-pool        >= 0.5 && < 0.6
+                    , hasql-transaction >= 1.0.1 && < 1.1
+                    , heredoc           >= 0.2 && < 0.3
+                    , hspec             >= 2.3 && < 2.9
+                    , hspec-wai         >= 0.10 && < 0.12
+                    , http-types        >= 0.12.3 && < 0.13
+                    , lens              >= 4.14 && < 5.1
+                    , lens-aeson        >= 1.0.1 && < 1.2
+                    , postgrest
+                    , process           >= 1.4.2 && < 1.7
+                    , protolude         >= 0.3.1 && < 0.4
+                    , regex-tdfa        >= 1.2.2 && < 1.4
+                    , wai-extra         >= 3.0.19 && < 3.2
+  ghc-options:        -O0 -Wall -fwarn-identities
+                      -fno-spec-constr -optP-Wno-nonportable-include-path
+                      -fwrite-ide-info
+                      -- https://github.com/PostgREST/postgrest/issues/387
+                      -with-rtsopts=-K1K
 
 test-suite doctests
-  type:                exitcode-stdio-1.0
-  default-language:    Haskell2010
-  default-extensions:  OverloadedStrings
-                       NoImplicitPrelude
-  hs-source-dirs:      test/doctests
-  main-is:             Main.hs
-  build-depends:       base              >= 4.9 && < 4.16
-                     , doctest           >= 0.8
-                     , postgrest
-                     , pretty-simple
-                     , protolude         >= 0.3 && < 0.4
-  ghc-options:         -threaded -O0 -Wall -fwarn-identities
-                       -fno-spec-constr -optP-Wno-nonportable-include-path
+  type:               exitcode-stdio-1.0
+  default-language:   Haskell2010
+  default-extensions: OverloadedStrings
+                      NoImplicitPrelude
+  hs-source-dirs:     test/doc
+  main-is:            Main.hs
+  build-depends:      base              >= 4.9 && < 4.16
+                    , doctest           >= 0.8
+                    , postgrest
+                    , pretty-simple
+                    , protolude         >= 0.3.1 && < 0.4
+  ghc-options:        -threaded -O0 -Wall -fwarn-identities
+                      -fno-spec-constr -optP-Wno-nonportable-include-path
diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs
--- a/src/PostgREST/App.hs
+++ b/src/PostgREST/App.hs
@@ -42,8 +42,10 @@
 
 import qualified PostgREST.AppState                 as AppState
 import qualified PostgREST.Auth                     as Auth
+import qualified PostgREST.Cors                     as Cors
 import qualified PostgREST.DbStructure              as DbStructure
 import qualified PostgREST.Error                    as Error
+import qualified PostgREST.Logger                   as Logger
 import qualified PostgREST.Middleware               as Middleware
 import qualified PostgREST.OpenAPI                  as OpenAPI
 import qualified PostgREST.Query.QueryBuilder       as QueryBuilder
@@ -137,8 +139,9 @@
 
 -- | PostgREST application
 postgrest :: LogLevel -> AppState.AppState -> IO () -> Wai.Application
-postgrest logLev appState connWorker =
-  Middleware.pgrstMiddleware logLev $
+postgrest logLevel appState connWorker =
+  Logger.middleware logLevel .
+  Cors.middleware $
     \req respond -> do
       time <- AppState.getTime appState
       conf <- AppState.getConfig appState
@@ -466,7 +469,7 @@
       OAFollowPriv ->
         OpenAPI.encode conf dbStructure
            <$> SQL.statement tSchema (DbStructure.accessibleTables ctxPgVersion configDbPreparedStatements)
-           <*> SQL.statement tSchema (DbStructure.accessibleProcs configDbPreparedStatements)
+           <*> SQL.statement tSchema (DbStructure.accessibleProcs ctxPgVersion configDbPreparedStatements)
            <*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)
       OAIgnorePriv ->
         OpenAPI.encode conf dbStructure
diff --git a/src/PostgREST/CLI.hs b/src/PostgREST/CLI.hs
--- a/src/PostgREST/CLI.hs
+++ b/src/PostgREST/CLI.hs
@@ -53,7 +53,6 @@
 dumpSchema :: AppState -> IO LBS.ByteString
 dumpSchema appState = do
   AppConfig{..} <- AppState.getConfig appState
-  actualPgVersion <- AppState.getPgVersion appState
   result <-
     let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
     SQL.use (AppState.getPool appState) $
@@ -61,7 +60,6 @@
         queryDbStructure
           (toList configDbSchemas)
           configDbExtraSearchPath
-          actualPgVersion
           configDbPreparedStatements
   SQL.release $ AppState.getPool appState
   case result of
diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs
--- a/src/PostgREST/Config.hs
+++ b/src/PostgREST/Config.hs
@@ -131,7 +131,7 @@
       ,("db-prepared-statements",        T.toLower . show . configDbPreparedStatements)
       ,("db-root-spec",              q . maybe mempty dumpQi . configDbRootSpec)
       ,("db-schemas",                q . T.intercalate "," . toList . configDbSchemas)
-      ,("db-config",                 q . T.toLower . show . configDbConfig)
+      ,("db-config",                     T.toLower . show . configDbConfig)
       ,("db-tx-end",                 q . showTxEnd)
       ,("db-uri",                    q . configDbUri)
       ,("db-use-legacy-gucs",            T.toLower . show . configDbUseLegacyGucs)
@@ -369,17 +369,17 @@
 
     coerceInt :: (Read i, Integral i) => C.Value -> Maybe i
     coerceInt (C.Number x) = rightToMaybe $ floatingOrInteger x
-    coerceInt (C.String x) = readMaybe $ toS x
+    coerceInt (C.String x) = readMaybe x
     coerceInt _            = Nothing
 
     coerceBool :: C.Value -> Maybe Bool
     coerceBool (C.Bool b)   = Just b
     coerceBool (C.String s) =
       -- parse all kinds of text: True, true, TRUE, "true", ...
-      case readMaybe . toS $ T.toTitle $ T.filter isAlpha $ toS s of
+      case readMaybe $ T.toTitle $ T.filter isAlpha $ toS s of
         Just b  -> Just b
         -- numeric instead?
-        Nothing -> (> 0) <$> (readMaybe $ toS s :: Maybe Integer)
+        Nothing -> (> 0) <$> (readMaybe s :: Maybe Integer)
     coerceBool _            = Nothing
 
     splitOnCommas :: C.Value -> [Text]
diff --git a/src/PostgREST/Config/Database.hs b/src/PostgREST/Config/Database.hs
--- a/src/PostgREST/Config/Database.hs
+++ b/src/PostgREST/Config/Database.hs
@@ -1,7 +1,8 @@
 {-# LANGUAGE QuasiQuotes #-}
 
 module PostgREST.Config.Database
-  ( queryDbSettings
+  ( pgVersionStatement
+  , queryDbSettings
   , queryPgVersion
   ) where
 
@@ -20,7 +21,10 @@
 import Protolude
 
 queryPgVersion :: Session PgVersion
-queryPgVersion = statement mempty $ SQL.Statement sql HE.noParams versionRow False
+queryPgVersion = statement mempty pgVersionStatement
+
+pgVersionStatement :: SQL.Statement () PgVersion
+pgVersionStatement = SQL.Statement sql HE.noParams versionRow False
   where
     sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')"
     versionRow = HD.singleRow $ PgVersion <$> column HD.int4 <*> column HD.text
@@ -36,19 +40,26 @@
 dbSettingsStatement = SQL.Statement sql HE.noParams decodeSettings False
   where
     sql = [q|
-      with
-      role_setting as (
-        select setdatabase, unnest(setconfig) as setting from pg_catalog.pg_db_role_setting
-        where setrole = current_user::regrole::oid
-          and setdatabase in (0, (select oid from pg_catalog.pg_database where datname = current_catalog))
+      WITH
+      role_setting (database, setting) AS (
+        SELECT setdatabase,
+               unnest(setconfig)
+          FROM pg_catalog.pg_db_role_setting
+         WHERE setrole = CURRENT_USER::regrole::oid
+           AND setdatabase IN (0, (SELECT oid FROM pg_catalog.pg_database WHERE datname = CURRENT_CATALOG))
       ),
-      kv_settings as (
-        select setdatabase, split_part(setting, '=', 1) as k, split_part(setting, '=', 2) as value from role_setting
-        where setting like 'pgrst.%'
+      kv_settings (database, k, v) AS (
+        SELECT database,
+               substr(setting, 1, strpos(setting, '=') - 1),
+               substr(setting, strpos(setting, '=') + 1)
+          FROM role_setting
+         WHERE setting LIKE 'pgrst.%'
       )
-      select distinct on (key) replace(k, 'pgrst.', '') as key, value
-      from kv_settings
-      order by key, setdatabase desc;
+      SELECT DISTINCT ON (key)
+             replace(k, 'pgrst.', '') AS key,
+             v AS value
+        FROM kv_settings
+       ORDER BY key, database DESC;
     |]
     decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text
 
diff --git a/src/PostgREST/Cors.hs b/src/PostgREST/Cors.hs
new file mode 100644
--- /dev/null
+++ b/src/PostgREST/Cors.hs
@@ -0,0 +1,42 @@
+{-|
+Module      : PostgREST.Cors
+Description : Wai Middleware to set cors policy.
+-}
+module PostgREST.Cors (middleware) where
+
+import qualified Data.ByteString.Char8       as BS
+import qualified Data.CaseInsensitive        as CI
+import qualified Network.Wai                 as Wai
+import qualified Network.Wai.Middleware.Cors as Wai
+
+import Data.List (lookup)
+
+import Protolude
+
+middleware :: Wai.Middleware
+middleware = Wai.cors corsPolicy
+
+-- | CORS policy to be used in by Wai Cors middleware
+corsPolicy :: Wai.Request -> Maybe Wai.CorsResourcePolicy
+corsPolicy req = case lookup "origin" headers of
+  Just origin ->
+    Just Wai.CorsResourcePolicy
+    { Wai.corsOrigins = Just ([origin], True)
+    , Wai.corsMethods = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
+    , Wai.corsRequestHeaders = "Authorization" : accHeaders
+    , Wai.corsExposedHeaders = Just
+      [ "Content-Encoding", "Content-Location", "Content-Range", "Content-Type"
+      , "Date", "Location", "Server", "Transfer-Encoding", "Range-Unit"]
+    , Wai.corsMaxAge = Just $ 60*60*24
+    , Wai.corsVaryOrigin = False
+    , Wai.corsRequireOrigin = False
+    , Wai.corsIgnoreFailures = True
+    }
+  Nothing -> Nothing
+  where
+    headers = Wai.requestHeaders req
+    accHeaders = case lookup "access-control-request-headers" headers of
+      Just hdrs -> map (CI.mk . BS.strip) $ BS.split ',' hdrs
+       -- Impossible case, Middleware.Cors will not evaluate this when
+       -- the Access-Control-Request-Headers header is not set.
+      Nothing   -> []
diff --git a/src/PostgREST/DbStructure.hs b/src/PostgREST/DbStructure.hs
--- a/src/PostgREST/DbStructure.hs
+++ b/src/PostgREST/DbStructure.hs
@@ -41,7 +41,9 @@
 import Data.Text                     (split)
 import Text.InterpolatedString.Perl6 (q)
 
-import PostgREST.Config.PgVersion         (PgVersion, pgVersion100)
+import PostgREST.Config.Database          (pgVersionStatement)
+import PostgREST.Config.PgVersion         (PgVersion, pgVersion100,
+                                           pgVersion110)
 import PostgREST.DbStructure.Identifiers  (QualifiedIdentifier (..),
                                            Schema, TableName)
 import PostgREST.DbStructure.Proc         (PgType (..),
@@ -83,15 +85,16 @@
 -- | A SQL query that can be executed independently
 type SqlQuery = ByteString
 
-queryDbStructure :: [Schema] -> [Schema] -> PgVersion -> Bool -> SQL.Transaction DbStructure
-queryDbStructure schemas extraSearchPath pgVer prepared = do
+queryDbStructure :: [Schema] -> [Schema] -> Bool -> SQL.Transaction DbStructure
+queryDbStructure schemas extraSearchPath prepared = do
   SQL.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
+  pgVer   <- SQL.statement mempty pgVersionStatement
   tabs    <- SQL.statement mempty $ allTables pgVer prepared
   cols    <- SQL.statement schemas $ allColumns tabs prepared
   srcCols <- SQL.statement (schemas, extraSearchPath) $ pfkSourceColumns cols prepared
   m2oRels <- SQL.statement mempty $ allM2ORels tabs cols prepared
   keys    <- SQL.statement mempty $ allPrimaryKeys tabs prepared
-  procs   <- SQL.statement schemas $ allProcs prepared
+  procs   <- SQL.statement schemas $ allProcs pgVer prepared
 
   let rels = addO2MRels . addM2MRels $ addViewM2ORels srcCols m2oRels
       keys' = addViewPrimaryKeys srcCols keys
@@ -224,18 +227,18 @@
                       | v == 's' = Stable
                       | otherwise = Volatile -- only 'v' can happen here
 
-allProcs :: Bool -> SQL.Statement [Schema] ProcsMap
-allProcs = SQL.Statement sql (arrayParam HE.text) decodeProcs
+allProcs :: PgVersion -> Bool -> SQL.Statement [Schema] ProcsMap
+allProcs pgVer = SQL.Statement sql (arrayParam HE.text) decodeProcs
   where
-    sql = procsSqlQuery <> " WHERE pn.nspname = ANY($1)"
+    sql = procsSqlQuery pgVer <> " AND pn.nspname = ANY($1)"
 
-accessibleProcs :: Bool -> SQL.Statement Schema ProcsMap
-accessibleProcs = SQL.Statement sql (param HE.text) decodeProcs
+accessibleProcs :: PgVersion -> Bool -> SQL.Statement Schema ProcsMap
+accessibleProcs pgVer = SQL.Statement sql (param HE.text) decodeProcs
   where
-    sql = procsSqlQuery <> " WHERE pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')"
+    sql = procsSqlQuery pgVer <> " AND pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')"
 
-procsSqlQuery :: SqlQuery
-procsSqlQuery = [q|
+procsSqlQuery :: PgVersion -> SqlQuery
+procsSqlQuery pgVer = [q|
  -- Recursively get the base types of domains
   WITH
   base_types AS (
@@ -297,7 +300,8 @@
   JOIN pg_namespace tn ON tn.oid = t.typnamespace
   LEFT JOIN pg_class comp ON comp.oid = t.typrelid
   LEFT JOIN pg_catalog.pg_description as d ON d.objoid = p.oid
-|]
+  WHERE t.oid <> 'pg_catalog.trigger'::regtype
+|] <> (if pgVer >= pgVersion110 then "AND prokind = 'f'" else "AND NOT (proisagg OR proiswindow)")
 
 schemaDescription :: Bool -> SQL.Statement Schema (Maybe Text)
 schemaDescription =
@@ -792,7 +796,6 @@
             replace(
             replace(
             replace(
-            replace(
             regexp_replace(
             replace(
             replace(
@@ -804,6 +807,7 @@
             replace(
             replace(
             replace(
+            replace(
               view_definition::text,
             -- This conversion to json is heavily optimized for performance.
             -- The general idea is to use as few regexp_replace() calls as possible.
@@ -814,9 +818,15 @@
             -- -----------------------------------------------
             -- pattern           | replacement         | flags
             -- -----------------------------------------------
+            -- `<>` in pg_node_tree is the same as `null` in JSON, but due to very poor performance of json_typeof
+            -- we need to make this an empty array here to prevent json_array_elements from throwing an error
+            -- when the targetList is null.
+            -- We'll need to put it first, to make the node protection below work for node lists that start with
+            -- null: `(<> ...`, too. This is the case for coldefexprs, when the first column does not have a default value.
+               '<>'              , '()'
             -- `,` is not part of the pg_node_tree format, but used in the regex.
             -- This removes all `,` that might be part of column names.
-               ','               , ''
+            ), ','               , ''
             -- The same applies for `{` and `}`, although those are used a lot in pg_node_tree.
             -- We remove the escaped ones, which might be part of column names again.
             ), E'\\{'            , ''
@@ -851,10 +861,6 @@
             ), ')'               , ']'
             -- pg_node_tree has ` ` between list items, but JSON uses `,`
             ), ' '             , ','
-            -- `<>` in pg_node_tree is the same as `null` in JSON, but due to very poor performance of json_typeof
-            -- we need to make this an empty array here to prevent json_array_elements from throwing an error
-            -- when the targetList is null.
-            ), '<>'              , '[]'
           )::json as view_definition
         from views
       ),
diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs
--- a/src/PostgREST/Error.hs
+++ b/src/PostgREST/Error.hs
@@ -126,23 +126,22 @@
 compressedRel :: Relationship -> JSON.Value
 compressedRel Relationship{..} =
   let
-    fmtTbl Table{..} = tableSchema <> "." <> tableName
-    fmtEls els = "[" <> T.intercalate ", " els <> "]"
+    fmtEls els = "(" <> T.intercalate ", " els <> ")"
   in
   JSON.object $
     ("embedding" .= (tableName relTable <> " with " <> tableName relForeignTable :: Text))
     : case relCardinality of
         M2M Junction{..} -> [
             "cardinality" .= ("many-to-many" :: Text)
-          , "relationship" .= (fmtTbl junTable <> fmtEls [junConstraint1] <> fmtEls [junConstraint2])
+          , "relationship" .= (tableName junTable <> " using " <> junConstraint1 <> fmtEls (colName <$> junColumns1) <> " and " <> junConstraint2 <> fmtEls (colName <$> junColumns2))
           ]
         M2O cons -> [
             "cardinality" .= ("many-to-one" :: Text)
-          , "relationship" .= (cons <> fmtEls (colName <$> relColumns) <> fmtEls (colName <$> relForeignColumns))
+          , "relationship" .= (cons <> " using " <> tableName relTable <> fmtEls (colName <$> relColumns) <> " and " <> tableName relForeignTable <> fmtEls (colName <$> relForeignColumns))
           ]
         O2M cons -> [
             "cardinality" .= ("one-to-many" :: Text)
-          , "relationship" .= (cons <> fmtEls (colName <$> relColumns) <> fmtEls (colName <$> relForeignColumns))
+          , "relationship" .= (cons <> " using " <> tableName relTable <> fmtEls (colName <$> relColumns) <> " and " <> tableName relForeignTable <> fmtEls (colName <$> relForeignColumns))
           ]
 
 relHint :: [Relationship] -> Text
diff --git a/src/PostgREST/Logger.hs b/src/PostgREST/Logger.hs
new file mode 100644
--- /dev/null
+++ b/src/PostgREST/Logger.hs
@@ -0,0 +1,28 @@
+{-|
+Module      : PostgREST.Logger
+Description : Wai Middleware to log requests to stdout.
+-}
+module PostgREST.Logger (middleware) where
+
+import qualified Network.Wai                          as Wai
+import qualified Network.Wai.Middleware.RequestLogger as Wai
+
+import Network.HTTP.Types.Status (status400, status500)
+import System.IO.Unsafe          (unsafePerformIO)
+
+import PostgREST.Config (LogLevel (..))
+
+import Protolude
+
+middleware :: LogLevel -> Wai.Middleware
+middleware logLevel = case logLevel of
+  LogInfo  -> requestLogger (const True)
+  LogWarn  -> requestLogger (>= status400)
+  LogError -> requestLogger (>= status500)
+  LogCrit  -> requestLogger (const False)
+  where
+    requestLogger filterStatus = unsafePerformIO $ Wai.mkRequestLogger Wai.defaultRequestLoggerSettings
+      { Wai.outputFormat = Wai.ApacheWithSettings $
+          Wai.defaultApacheSettings
+            & Wai.setApacheRequestFilter (\_ res -> filterStatus $ Wai.responseStatus res)
+      }
diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs
--- a/src/PostgREST/Middleware.hs
+++ b/src/PostgREST/Middleware.hs
@@ -2,47 +2,29 @@
 Module      : PostgREST.Middleware
 Description : Sets CORS policy. Also the PostgreSQL GUCs, role, search_path and pre-request function.
 -}
+{-# LANGUAGE BlockArguments  #-}
 {-# LANGUAGE RecordWildCards #-}
 module PostgREST.Middleware
   ( runPgLocals
-  , pgrstFormat
-  , pgrstMiddleware
-  , defaultCorsPolicy
-  , corsPolicy
   , optionalRollback
   ) where
 
-import qualified Data.Aeson                           as JSON
-import qualified Data.ByteString.Char8                as BS
-import qualified Data.ByteString.Lazy.Char8           as LBS
-import qualified Data.CaseInsensitive                 as CI
-import qualified Data.HashMap.Strict                  as M
-import qualified Data.Text                            as T
-import qualified Data.Text.Encoding                   as T
-import qualified Hasql.Decoders                       as HD
-import qualified Hasql.DynamicStatements.Snippet      as SQL hiding
-                                                             (sql)
-import qualified Hasql.DynamicStatements.Statement    as SQL
-import qualified Hasql.Transaction                    as SQL
-import qualified Network.Wai                          as Wai
-import qualified Network.Wai.Logger                   as Wai
-import qualified Network.Wai.Middleware.Cors          as Wai
-import qualified Network.Wai.Middleware.Gzip          as Wai
-import qualified Network.Wai.Middleware.RequestLogger as Wai
-import qualified Network.Wai.Middleware.Static        as Wai
+import qualified Data.Aeson                        as JSON
+import qualified Data.ByteString.Lazy.Char8        as LBS
+import qualified Data.HashMap.Strict               as M
+import qualified Data.Text                         as T
+import qualified Data.Text.Encoding                as T
+import qualified Hasql.Decoders                    as HD
+import qualified Hasql.DynamicStatements.Snippet   as SQL hiding (sql)
+import qualified Hasql.DynamicStatements.Statement as SQL
+import qualified Hasql.Transaction                 as SQL
+import qualified Network.Wai                       as Wai
 
 import Control.Arrow ((***))
 
-import Data.Function             (id)
-import Data.List                 (lookup)
-import Data.Scientific           (FPFormat (..), formatScientific,
-                                  isInteger)
-import Network.HTTP.Types.Status (Status, status400, status500,
-                                  statusCode)
-import System.IO.Unsafe          (unsafePerformIO)
-import System.Log.FastLogger     (toLogStr)
+import Data.Scientific (FPFormat (..), formatScientific, isInteger)
 
-import PostgREST.Config             (AppConfig (..), LogLevel (..))
+import PostgREST.Config             (AppConfig (..))
 import PostgREST.Config.PgVersion   (PgVersion (..), pgVersion140)
 import PostgREST.Error              (Error, errorResponseFor)
 import PostgREST.GucHeader          (addHeadersIfNotIncluded)
@@ -90,76 +72,12 @@
       _                             -> mempty
     usesLegacyGucs = configDbUseLegacyGucs conf && actualPgVersion < pgVersion140
 
--- | Log in apache format. Only requests that have a status greater than minStatus are logged.
--- | There's no way to filter logs in the apache format on wai-extra: https://hackage.haskell.org/package/wai-extra-3.0.29.2/docs/Network-Wai-Middleware-RequestLogger.html#t:OutputFormat.
--- | So here we copy wai-logger apacheLogStr function: https://github.com/kazu-yamamoto/logger/blob/a4f51b909a099c51af7a3f75cf16e19a06f9e257/wai-logger/Network/Wai/Logger/Apache.hs#L45
--- | TODO: Add the ability to filter apache logs on wai-extra and remove this function.
-pgrstFormat :: Status -> Wai.OutputFormatter
-pgrstFormat minStatus date req status responseSize =
-  if status < minStatus
-    then mempty
-  else toLogStr (getSourceFromSocket req)
-    <> " - - ["
-    <> toLogStr date
-    <> "] \""
-    <> toLogStr (Wai.requestMethod req)
-    <> " "
-    <> toLogStr (Wai.rawPathInfo req <> Wai.rawQueryString req)
-    <> " "
-    <> toLogStr (show (Wai.httpVersion req)::Text)
-    <> "\" "
-    <> toLogStr (show (statusCode status)::Text)
-    <> " "
-    <> toLogStr (maybe "-" show responseSize::Text)
-    <> " \""
-    <> toLogStr (fromMaybe mempty $ Wai.requestHeaderReferer req)
-    <> "\" \""
-    <> toLogStr (fromMaybe mempty $ Wai.requestHeaderUserAgent req)
-    <> "\"\n"
-  where
-    getSourceFromSocket = BS.pack . Wai.showSockAddr . Wai.remoteHost
-
-pgrstMiddleware :: LogLevel -> Wai.Application -> Wai.Application
-pgrstMiddleware logLevel =
-    logger
-  . Wai.cors corsPolicy
-  . Wai.staticPolicy (Wai.only [("favicon.ico", "static/favicon.ico")])
-  where
-    logger = case logLevel of
-      LogCrit  -> id
-      LogError -> unsafePerformIO $ Wai.mkRequestLogger Wai.def { Wai.outputFormat = Wai.CustomOutputFormat $ pgrstFormat status500}
-      LogWarn  -> unsafePerformIO $ Wai.mkRequestLogger Wai.def { Wai.outputFormat = Wai.CustomOutputFormat $ pgrstFormat status400}
-      LogInfo  -> Wai.logStdout
-
-defaultCorsPolicy :: Wai.CorsResourcePolicy
-defaultCorsPolicy =  Wai.CorsResourcePolicy Nothing
-  ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing
-  (Just $ 60*60*24) False False True
-
--- | CORS policy to be used in by Wai Cors middleware
-corsPolicy :: Wai.Request -> Maybe Wai.CorsResourcePolicy
-corsPolicy req = case lookup "origin" headers of
-  Just origin -> Just defaultCorsPolicy {
-      Wai.corsOrigins = Just ([origin], True)
-    , Wai.corsRequestHeaders = "Authentication" : accHeaders
-    , Wai.corsExposedHeaders = Just [
-        "Content-Encoding", "Content-Location", "Content-Range", "Content-Type"
-      , "Date", "Location", "Server", "Transfer-Encoding", "Range-Unit"
-      ]
-    }
-  Nothing -> Nothing
-  where
-    headers = Wai.requestHeaders req
-    accHeaders = case lookup "access-control-request-headers" headers of
-      Just hdrs -> map (CI.mk . BS.strip) $ BS.split ',' hdrs
-      Nothing   -> []
-
-unquoted :: JSON.Value -> Text
-unquoted (JSON.String t) = t
-unquoted (JSON.Number n) =
-  toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
-unquoted (JSON.Bool b) = show b
-unquoted v = T.decodeUtf8 . LBS.toStrict $ JSON.encode v
+    unquoted :: JSON.Value -> Text
+    unquoted (JSON.String t) = t
+    unquoted (JSON.Number n) =
+      toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
+    unquoted (JSON.Bool b) = show b
+    unquoted v = T.decodeUtf8 . LBS.toStrict $ JSON.encode v
 
 -- | Set a transaction to eventually roll back if requested and set respective
 -- headers on the response.
@@ -170,8 +88,9 @@
   -> ExceptT Error SQL.Transaction Wai.Response
 optionalRollback AppConfig{..} ApiRequest{..} transaction = do
   resp <- catchError transaction $ return . errorResponseFor
-  when (shouldRollback || (configDbTxRollbackAll && not shouldCommit))
-    (lift SQL.condemn)
+  when (shouldRollback || (configDbTxRollbackAll && not shouldCommit)) $ lift do
+    SQL.sql "SET CONSTRAINTS ALL IMMEDIATE"
+    SQL.condemn
   return $ Wai.mapResponseHeaders preferenceApplied resp
   where
     shouldCommit =
diff --git a/src/PostgREST/OpenAPI.hs b/src/PostgREST/OpenAPI.hs
--- a/src/PostgREST/OpenAPI.hs
+++ b/src/PostgREST/OpenAPI.hs
@@ -55,24 +55,26 @@
 makeMimeList :: [ContentType] -> MimeList
 makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs
 
-toSwaggerType :: Text -> SwaggerType t
-toSwaggerType "character varying" = SwaggerString
-toSwaggerType "character"         = SwaggerString
-toSwaggerType "text"              = SwaggerString
-toSwaggerType "boolean"           = SwaggerBoolean
-toSwaggerType "smallint"          = SwaggerInteger
-toSwaggerType "integer"           = SwaggerInteger
-toSwaggerType "bigint"            = SwaggerInteger
-toSwaggerType "numeric"           = SwaggerNumber
-toSwaggerType "real"              = SwaggerNumber
-toSwaggerType "double precision"  = SwaggerNumber
-toSwaggerType "ARRAY"             = SwaggerArray
-toSwaggerType _                   = SwaggerString
+toSwaggerType :: Text -> Maybe (SwaggerType t)
+toSwaggerType "character varying" = Just SwaggerString
+toSwaggerType "character"         = Just SwaggerString
+toSwaggerType "text"              = Just SwaggerString
+toSwaggerType "boolean"           = Just SwaggerBoolean
+toSwaggerType "smallint"          = Just SwaggerInteger
+toSwaggerType "integer"           = Just SwaggerInteger
+toSwaggerType "bigint"            = Just SwaggerInteger
+toSwaggerType "numeric"           = Just SwaggerNumber
+toSwaggerType "real"              = Just SwaggerNumber
+toSwaggerType "double precision"  = Just SwaggerNumber
+toSwaggerType "ARRAY"             = Just SwaggerArray
+toSwaggerType "json"              = Nothing
+toSwaggerType "jsonb"             = Nothing
+toSwaggerType _                   = Just SwaggerString
 
 parseDefault :: Text -> Text -> Text
 parseDefault colType colDefault =
   case toSwaggerType colType of
-    SwaggerString -> wrapInQuotations $ case T.stripSuffix ("::" <> colType) colDefault of
+    Just SwaggerString -> wrapInQuotations $ case T.stripSuffix ("::" <> colType) colDefault of
       Just def -> T.dropAround (=='\'')  def
       Nothing  -> colDefault
     _ -> colDefault
@@ -124,7 +126,7 @@
         & enum_ .~ e
         & format ?~ colType c
         & maxLength .~ (fromIntegral <$> colMaxLen c)
-        & type_ ?~ toSwaggerType (colType c)
+        & type_ .~ toSwaggerType (colType c)
 
 makeProcSchema :: ProcDescription -> Schema
 makeProcSchema pd =
@@ -138,7 +140,7 @@
 makeProcProperty (ProcParam n t _ _) = (n, Inline s)
   where
     s = (mempty :: Schema)
-          & type_ ?~ toSwaggerType t
+          & type_ .~ toSwaggerType t
           & format ?~ t
 
 makePreferParam :: [Text] -> Param
diff --git a/src/PostgREST/Query/SqlFragment.hs b/src/PostgREST/Query/SqlFragment.hs
--- a/src/PostgREST/Query/SqlFragment.hs
+++ b/src/PostgREST/Query/SqlFragment.hs
@@ -172,10 +172,10 @@
   | returnsScalar = "coalesce(json_agg(_postgrest_t.pgrst_scalar), '[]')::character varying"
   | otherwise     = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
 
-asJsonSingleF :: Bool -> SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
+asJsonSingleF :: Bool -> SqlFragment
 asJsonSingleF returnsScalar
-  | returnsScalar = "coalesce(string_agg(to_json(_postgrest_t.pgrst_scalar)::text, ','), 'null')::character varying"
-  | otherwise     = "coalesce(string_agg(to_json(_postgrest_t)::text, ','), '')::character varying"
+  | returnsScalar = "coalesce((json_agg(_postgrest_t.pgrst_scalar)->0)::text, 'null')"
+  | otherwise     = "coalesce((json_agg(_postgrest_t)->0)::text, 'null')"
 
 asBinaryF :: FieldName -> SqlFragment
 asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
diff --git a/src/PostgREST/Request/ApiRequest.hs b/src/PostgREST/Request/ApiRequest.hs
--- a/src/PostgREST/Request/ApiRequest.hs
+++ b/src/PostgREST/Request/ApiRequest.hs
@@ -364,9 +364,9 @@
   headerRange = rangeRequested hdrs
   replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) ++ [x]
   limitParams :: M.HashMap Text NonnegRange
-  limitParams  = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe . toS =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]
+  limitParams  = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]
   offsetParams :: M.HashMap Text NonnegRange
-  offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe . toS =<< v)) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]
+  offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe =<< v)) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]
 
   urlRange = M.unionWith f limitParams offsetParams
     where
@@ -505,7 +505,7 @@
         then length params == 1 && (ppType <$> headMay params) `elem` [Just "json", Just "jsonb"]
       -- If the function has no parameters, the arguments keys must be empty as well
       else if null params
-        then null argumentsKeys && contentType `notElem` [CTTextPlain, CTOctetStream]
+        then null argumentsKeys && not (isInvPost && contentType `elem` [CTTextPlain, CTOctetStream])
       -- A function has optional and required parameters. Optional parameters have a default value and
       -- don't require arguments for the function to be executed, required parameters must have an argument present.
       else case L.partition ppReq params of
diff --git a/src/PostgREST/Request/DbRequestBuilder.hs b/src/PostgREST/Request/DbRequestBuilder.hs
--- a/src/PostgREST/Request/DbRequestBuilder.hs
+++ b/src/PostgREST/Request/DbRequestBuilder.hs
@@ -56,7 +56,7 @@
 
 import qualified PostgREST.DbStructure.Relationship as Relationship
 
-import Protolude hiding (from)
+import Protolude hiding (from, isInfixOf)
 
 -- | Builds the ReadRequest tree on a number of stages.
 -- | Adds filters, order, limits on its respective nodes.
diff --git a/src/PostgREST/Request/Parsers.hs b/src/PostgREST/Request/Parsers.hs
--- a/src/PostgREST/Request/Parsers.hs
+++ b/src/PostgREST/Request/Parsers.hs
@@ -199,10 +199,10 @@
       <|> pFts
       <?> "operator (eq, gt, ...)"
 
-    pTriVal = try (string "null"    $> TriNull)
-          <|> try (string "unknown" $> TriUnknown)
-          <|> try (string "true"    $> TriTrue)
-          <|> try (string "false"   $> TriFalse)
+    pTriVal = try (ciString "null"    $> TriNull)
+          <|> try (ciString "unknown" $> TriUnknown)
+          <|> try (ciString "true"    $> TriTrue)
+          <|> try (ciString "false"   $> TriFalse)
           <?> "null or trilean value (unknown, true, false)"
 
     pFts = do
@@ -212,6 +212,12 @@
 
     ops = M.filterWithKey (const . flip notElem ("in":"is":ftsOps)) operators
     ftsOps = M.keys ftsOperators
+
+    -- case insensitive char and string
+    ciChar :: Char -> GenParser Char state Char
+    ciChar c = char c <|> char (toUpper c)
+    ciString :: [Char] -> GenParser Char state [Char]
+    ciString = traverse ciChar
 
 pSingleVal :: Parser SingleVal
 pSingleVal = toS <$> many anyChar
diff --git a/src/PostgREST/Version.hs b/src/PostgREST/Version.hs
--- a/src/PostgREST/Version.hs
+++ b/src/PostgREST/Version.hs
@@ -30,10 +30,12 @@
 
 
 -- | Version number used in docs.
+-- Pre-release versions link to the latest docs
 -- Uses only the two first components of the version. Example: 'v1.1'
 docsVersion :: Text
-docsVersion =
-  "v" <> (T.intercalate "." . map show . take 2 $ versionBranch version)
+docsVersion
+  | isPreRelease = "latest"
+  | otherwise    =  "v" <> (T.intercalate "." . map show . take 2 $ versionBranch version)
 
 
 -- | Versions with four components (e.g., '1.1.1.1') are treated as pre-releases.
diff --git a/src/PostgREST/Workers.hs b/src/PostgREST/Workers.hs
--- a/src/PostgREST/Workers.hs
+++ b/src/PostgREST/Workers.hs
@@ -152,11 +152,10 @@
 loadSchemaCache :: AppState -> IO SCacheStatus
 loadSchemaCache appState = do
   AppConfig{..} <- AppState.getConfig appState
-  actualPgVersion <- AppState.getPgVersion appState
   result <-
     let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
     SQL.use (AppState.getPool appState) . transaction SQL.ReadCommitted SQL.Read $
-      queryDbStructure (toList configDbSchemas) configDbExtraSearchPath actualPgVersion configDbPreparedStatements
+      queryDbStructure (toList configDbSchemas) configDbExtraSearchPath configDbPreparedStatements
   case result of
     Left e -> do
       let
diff --git a/test/Feature/AndOrParamsSpec.hs b/test/Feature/AndOrParamsSpec.hs
deleted file mode 100644
--- a/test/Feature/AndOrParamsSpec.hs
+++ /dev/null
@@ -1,276 +0,0 @@
-module Feature.AndOrParamsSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import PostgREST.Config.PgVersion (PgVersion, pgVersion112)
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: PgVersion -> SpecWith ((), Application)
-spec actualPgVersion =
-  describe "and/or params used for complex boolean logic" $ do
-    context "used with GET" $ do
-      context "or param" $ do
-        it "can do simple logic" $
-          get "/entities?or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can negate simple logic" $
-          get "/entities?not.or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
-            [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can be combined with traditional filters" $
-          get "/entities?or=(id.eq.1,id.eq.2)&name=eq.entity 1&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
-
-      context "embedded levels" $ do
-        it "can do logic on the second level" $
-          get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities(id)" `shouldRespondWith`
-            [json|[
-              {"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []},
-              {"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
-            ]|] { matchHeaders = [matchContentTypeJson] }
-
-        it "can do logic on the third level" $
-          get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities(id,grandchild_entities(id))"
-            `shouldRespondWith`
-              [json|[
-                {"id": 1, "child_entities": [
-                  { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]},
-                  { "id": 2, "grandchild_entities": []},
-                  { "id": 4, "grandchild_entities": []},
-                  { "id": 5, "grandchild_entities": []}
-                ]},
-                {"id": 2, "child_entities": [
-                  { "id": 3, "grandchild_entities": []},
-                  { "id": 6, "grandchild_entities": []}
-                ]},
-                {"id": 3, "child_entities": []},
-                {"id": 4, "child_entities": []}
-              ]|]
-
-      context "and/or params combined" $ do
-        it "can be nested inside the same expression" $
-          get "/entities?or=(and(name.eq.entity 2,id.eq.2),and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can be negated while nested" $
-          get "/entities?or=(not.and(name.eq.entity 2,id.eq.2),not.and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can be combined unnested" $
-          get "/entities?and=(id.eq.1,name.eq.entity 1)&or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
-
-      context "operators inside and/or" $ do
-        it "can handle eq and neq" $
-          get "/entities?and=(id.eq.1,id.neq.2))&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can handle lt and gt" $
-          get "/entities?or=(id.lt.2,id.gt.3)&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can handle lte and gte" $
-          get "/entities?or=(id.lte.2,id.gte.3)&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can handle like and ilike" $
-          get "/entities?or=(name.like.*1,name.ilike.*ENTITY 2)&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can handle in" $
-          get "/entities?or=(id.in.(1,2),id.in.(3,4))&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can handle is" $
-          get "/entities?and=(name.is.null,arr.is.null)&select=id" `shouldRespondWith`
-            [json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "can handle fts" $ do
-          get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith`
-            [json|[
-              {"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
-              {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
-              {"text_search_vector": "'art':4 'spass':5 'unmog':7"}
-            ]|] { matchHeaders = [matchContentTypeJson] }
-
-        when (actualPgVersion >= pgVersion112) $
-          it "can handle wfts (websearch_to_tsquery)" $
-            get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"
-            `shouldRespondWith`
-              [json|[
-                     {"text_search_vector": "'also':2 'fun':3 'possibl':8" },
-                     {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },
-                     {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
-                     {"text_search_vector": "'art':4 'spass':5 'unmog':7" }
-              ]|]
-              { matchHeaders = [matchContentTypeJson] }
-
-        it "can handle cs and cd" $
-          get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-
-        it "can handle range operators" $ do
-          get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=neq.[1,3]&select=id" `shouldRespondWith`
-            [json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=lt.[1,10]&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=gt.[8,11]&select=id" `shouldRespondWith`
-            [json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=lte.[1,3]&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=gte.[2,3]&select=id" `shouldRespondWith`
-            [json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=cs.[1,2]&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=cd.[1,6]&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=ov.[0,4]&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=sl.[9,10]&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=sr.[3,4]&select=id" `shouldRespondWith`
-            [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=nxr.[4,7]&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=nxl.[4,7]&select=id" `shouldRespondWith`
-            [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
-
-        it "can handle array operators" $ do
-          get "/entities?arr=eq.{1,2,3}&select=id" `shouldRespondWith`
-            [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=neq.{1,2}&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=lt.{2,3}&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=lt.{2,0}&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=gt.{1,1}&select=id" `shouldRespondWith`
-            [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=gt.{3}&select=id" `shouldRespondWith`
-            [json|[]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=lte.{2,1}&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=lte.{1,2,3}&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=lte.{1,2}&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=cs.{1,2}&select=id" `shouldRespondWith`
-            [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=cd.{1,2,6}&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=ov.{3}&select=id" `shouldRespondWith`
-            [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?arr=ov.{2,3}&select=id" `shouldRespondWith`
-            [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-
-        context "operators with not" $ do
-          it "eq, cs, like can be negated" $
-            get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
-              [json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
-          it "in, is, fts can be negated" $
-            get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith`
-              [json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
-          it "lt, gte, cd can be negated" $
-            get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
-              [json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
-          it "gt, lte, ilike can be negated" $
-            get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`
-              [json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
-
-      context "and/or params with quotes" $ do
-        it "eq can have quotes" $
-          get "/grandchild_entities?or=(name.eq.\"(grandchild,entity,4)\",name.eq.\"(grandchild,entity,5)\")&select=id" `shouldRespondWith`
-            [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "like and ilike can have quotes" $
-          get "/grandchild_entities?or=(name.like.\"*ity,4*\",name.ilike.\"*ITY,5)\")&select=id" `shouldRespondWith`
-            [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
-        it "in can have quotes" $
-          get "/grandchild_entities?or=(id.in.(\"1\",\"2\"),id.in.(\"3\",\"4\"))&select=id" `shouldRespondWith`
-            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
-
-      it "allows whitespace" $
-        get "/entities?and=( and ( id.in.( 1, 2, 3 ) , id.eq.3 ) , or ( id.eq.2 , id.eq.3 ) )&select=id" `shouldRespondWith`
-          [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
-
-      context "multiple and/or conditions" $ do
-        it "cannot have zero conditions" $
-          get "/entities?or=()" `shouldRespondWith`
-            [json|{
-              "details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)",
-              "message": "\"failed to parse logic tree (())\" (line 1, column 4)"
-            }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
-        it "can have a single condition" $ do
-          get "/entities?or=(id.eq.1)&select=id" `shouldRespondWith`
-            [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
-          get "/entities?and=(id.eq.1)&select=id" `shouldRespondWith`
-            [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
-        it "can have three conditions" $ do
-          get "/grandchild_entities?or=(id.eq.1, id.eq.2, id.eq.3)&select=id" `shouldRespondWith`
-            [json|[{"id":1}, {"id":2}, {"id":3}]|] { matchHeaders = [matchContentTypeJson] }
-          get "/grandchild_entities?and=(id.in.(1,2), id.in.(3,1), id.in.(1,4))&select=id" `shouldRespondWith`
-            [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
-        it "can have four conditions combining and/or" $ do
-          get "/grandchild_entities?or=( id.eq.1, id.eq.2, and(id.in.(1,3), id.in.(2,3)), id.eq.4 )&select=id" `shouldRespondWith`
-            [json|[{"id":1}, {"id":2}, {"id":3}, {"id":4}]|] { matchHeaders = [matchContentTypeJson] }
-          get "/grandchild_entities?and=( id.eq.1, not.or(id.eq.2, id.eq.3), id.in.(1,4), or(id.eq.1, id.eq.4) )&select=id" `shouldRespondWith`
-            [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
-
-    context "used with POST" $
-      it "includes related data with filters" $
-        request methodPost "/child_entities?select=id,entities(id)&entities.or=(id.eq.2,id.eq.3)&entities.order=id"
-            [("Prefer", "return=representation")]
-            [json|[
-              {"id":7,"name":"entity 4","parent_id":1},
-              {"id":8,"name":"entity 5","parent_id":2},
-              {"id":9,"name":"entity 6","parent_id":3}
-            ]|]
-          `shouldRespondWith`
-            [json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|]
-            { matchStatus = 201 }
-
-    context "used with PATCH" $
-      it "succeeds when using and/or params" $
-        request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
-          [("Prefer", "return=representation")]
-          [json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
-          [json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-    context "used with DELETE" $
-      it "succeeds when using and/or params" $
-        request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
-            [("Prefer", "return=representation")]
-            ""
-          `shouldRespondWith`
-            [json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|]
-
-    it "can query columns that begin with and/or reserved words" $
-      get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
-
-    it "fails when using IN without () and provides meaningful error message" $
-      get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`
-        [json|{
-          "details": "unexpected \"1\" expecting \"(\"",
-          "message": "\"failed to parse logic tree ((id.in.1,2,id.eq.3))\" (line 1, column 10)"
-        }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
-
-    it "fails on malformed query params and provides meaningful error message" $ do
-      get "/entities?or=)(" `shouldRespondWith`
-        [json|{
-          "details": "unexpected \")\" expecting \"(\"",
-          "message": "\"failed to parse logic tree ()()\" (line 1, column 3)"
-        }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
-      get "/entities?and=(ord(id.eq.1,id.eq.1),id.eq.2)" `shouldRespondWith`
-        [json|{
-          "details": "unexpected \"d\" expecting \"(\"",
-          "message": "\"failed to parse logic tree ((ord(id.eq.1,id.eq.1),id.eq.2))\" (line 1, column 7)"
-        }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
-      get "/entities?or=(id.eq.1,not.xor(id.eq.2,id.eq.3))" `shouldRespondWith`
-        [json|{
-          "details": "unexpected \"x\" expecting logic operator (and, or)",
-          "message": "\"failed to parse logic tree ((id.eq.1,not.xor(id.eq.2,id.eq.3)))\" (line 1, column 16)"
-        }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
diff --git a/test/Feature/AsymmetricJwtSpec.hs b/test/Feature/AsymmetricJwtSpec.hs
deleted file mode 100644
--- a/test/Feature/AsymmetricJwtSpec.hs
+++ /dev/null
@@ -1,21 +0,0 @@
-module Feature.AsymmetricJwtSpec where
-
--- {{{ Imports
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-
-import Protolude
-import SpecHelper
--- }}}
-
-spec :: SpecWith ((), Application)
-spec = describe "server started with asymmetric JWK" $
-
-  -- this test will stop working 9999999999s after the UNIX EPOCH
-  it "succeeds with jwt token signed with an asymmetric key" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlIjogInBvc3RncmVzdF90ZXN0X2F1dGhvciJ9Cg.CBOYWDvqgAR0YYnZnyDGTQi6AJLc2Pds6_eV3YuBG6I36mj_h05eLhkEKNEDA5ZteMzCiY83P60rC_xtxVd7B6vo3BeF5uoanPS3rrbuHzKPwzsrgrD_CqvEuJ4n7Q9epkQiLsNkcexneENZDRqFjbwZx3DrXiCWwlK3Ytr5NAIGxmy0od-0xNpb2U1nXQyO_Q3mumWFViRt4tmFn_3goDHNKG3Ha_AzImfUNvHnWL78kAc4rbn15vLtWXD8PwtSnZaB4lY4V6RfsaW937srQsmRetvytM1i_bHBnjkjQLAqGbXPyItjtlXPs0uGNBadE8-wgkLtfmSCC4v2DjUthw"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 200
diff --git a/test/Feature/AudienceJwtSecretSpec.hs b/test/Feature/AudienceJwtSecretSpec.hs
deleted file mode 100644
--- a/test/Feature/AudienceJwtSecretSpec.hs
+++ /dev/null
@@ -1,47 +0,0 @@
-module Feature.AudienceJwtSecretSpec where
-
--- {{{ Imports
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-
-import Protolude  hiding (get)
-import SpecHelper
--- }}}
-
-spec :: SpecWith ((), Application)
-spec = describe "test handling of aud claims in JWT" $ do
-
-  -- this test will stop working 9999999999s after the UNIX EPOCH
-  it "succeeds with jwt token containing with an audience claim" $ do
-    {-  This is the decoded contents of authHeaderJWT
-
-        {
-          "exp": 9999999999,
-          "role": "postgrest_test_author",
-          "id": "jdoe",
-          "aud": "youraudience"
-        }
-
-    -}
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 200
-
-  it "succeeds with jwt token that does not contain an audience claim" $ do
-    {- This is the decoded contents of authHeaderJWT
-
-        {
-          "exp": 9999999999,
-          "role": "postgrest_test_author",
-          "id": "jdoe"
-        }
-    -}
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 200
-
-  it "requests without JWT token should work" $
-    get "/has_count_column" `shouldRespondWith` 200
diff --git a/test/Feature/AuthSpec.hs b/test/Feature/AuthSpec.hs
deleted file mode 100644
--- a/test/Feature/AuthSpec.hs
+++ /dev/null
@@ -1,179 +0,0 @@
-module Feature.AuthSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import PostgREST.Config.PgVersion (PgVersion, pgVersion112)
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: PgVersion -> SpecWith ((), Application)
-spec actualPgVersion = describe "authorization" $ do
-  let single = ("Accept","application/vnd.pgrst.object+json")
-
-  it "denies access to tables that anonymous does not own" $
-    get "/authors_only" `shouldRespondWith` (
-        if actualPgVersion >= pgVersion112 then
-        [json| {
-          "hint":null,
-          "details":null,
-          "code":"42501",
-          "message":"permission denied for table authors_only"} |]
-           else
-        [json| {
-          "hint":null,
-          "details":null,
-          "code":"42501",
-          "message":"permission denied for relation authors_only"} |]
-                                            )
-      { matchStatus = 401
-      , matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
-      }
-
-  it "denies access to tables that postgrest_test_author does not own" $
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
-    request methodGet "/private_table" [auth] ""
-      `shouldRespondWith` (
-        if actualPgVersion >= pgVersion112 then
-        [json| {
-          "hint":null,
-          "details":null,
-          "code":"42501",
-          "message":"permission denied for table private_table"} |]
-           else
-        [json| {
-          "hint":null,
-          "details":null,
-          "code":"42501",
-          "message":"permission denied for relation private_table"} |]
-                          )
-      { matchStatus = 403
-      , matchHeaders = []
-      }
-
-  it "denies execution on functions that anonymous does not own" $
-    post "/rpc/privileged_hello" [json|{"name": "anonymous"}|] `shouldRespondWith` 401
-
-  it "allows execution on a function that postgrest_test_author owns" $
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
-    request methodPost "/rpc/privileged_hello" [auth] [json|{"name": "jdoe"}|]
-      `shouldRespondWith` [json|"Privileged hello to jdoe"|]
-      { matchStatus = 200
-      , matchHeaders = [matchContentTypeJson]
-      }
-
-  it "returns jwt functions as jwt tokens" $
-    request methodPost "/rpc/login" [single]
-      [json| { "id": "jdoe", "pass": "1234" } |]
-      `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.KO-0PGp_rU-utcDBP6qwdd-Th2Fk-ICVt01I7QtTDWs"} |]
-        { matchStatus = 200
-        , matchHeaders = [matchContentTypeSingular]
-        }
-
-  it "sql functions can encode custom and standard claims" $
-    request methodPost  "/rpc/jwt_test" [single] "{}"
-      `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.G2REtPnOQMUrVRDA9OnkPJTd8R0tf4wdYOlauh1E2Ek"} |]
-        { matchStatus = 200
-        , matchHeaders = [matchContentTypeSingular]
-        }
-
-  it "sql functions can read custom and standard claims variables" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6OTk5OTk5OTk5OSwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWF0IjoxMzAwODE5MzgwfQ.V5fEpXfpb7feqwVqlcDleFdKu86bdwU2cBRT4fcMhXg"
-    request methodPost "/rpc/reveal_big_jwt" [auth] "{}"
-      `shouldRespondWith` [json|[{"iss":"joe","sub":"fun","exp":9999999999,"nbf":1300819380,"iat":1300819380,"jti":"foo","http://postgrest.com/foo":true}]|]
-
-  it "allows users with permissions to see their tables" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 200
-
-  it "works with tokens which have extra fields" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIiwia2V5MSI6InZhbHVlMSIsImtleTIiOiJ2YWx1ZTIiLCJrZXkzIjoidmFsdWUzIiwiYSI6MSwiYiI6MiwiYyI6M30.b0eglDKYEmGi-hCvD-ddSqFl7vnDO5qkUaviaHXm3es"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 200
-
-  -- this test will stop working 9999999999s after the UNIX EPOCH
-  it "succeeds with an unexpired token" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 200
-
-  it "fails with an expired token" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.f8__E6VQwYcDqwHmr9PG03uaZn8Zh1b0vbJ9DYS0AdM"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` [json| {"message":"JWT expired"} |]
-        { matchStatus = 401
-        , matchHeaders = [
-            "WWW-Authenticate" <:>
-            "Bearer error=\"invalid_token\", error_description=\"JWT expired\""
-          ]
-        }
-
-  it "hides tables from users with invalid JWT" $ do
-    let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"} |]
-        { matchStatus = 401
-        , matchHeaders = [
-            "WWW-Authenticate" <:>
-            "Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""
-          ]
-        }
-
-  it "should fail when jwt contains no claims" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.CUIP5V9thWsGGFsFyGijSZf1fJMfarLHI9CEJL-TGNk"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 401
-
-  it "hides tables from users with JWT that contain no claims about role" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.RVlZDaSyKbFPvxUf3V_NQXybfRB4dlBIkAUQXVXLUAI"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 401
-
-  it "recovers after 401 error with logged in user" $ do
-    _ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
-    _ <- request methodPost "/rpc/problem" [auth] ""
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 200
-
-  describe "custom pre-request proc acting on id claim" $ do
-
-    it "able to switch to postgrest_test_author role (id=1)" $
-      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.gKw7qI50i9hMrSJW8BlTpdMEVmMXJYxlAqueGqpa_mE" in
-      request methodPost "/rpc/get_current_user" [auth]
-        [json| {} |]
-         `shouldRespondWith` [json|"postgrest_test_author"|]
-          { matchStatus = 200
-          , matchHeaders = []
-          }
-
-    it "able to switch to postgrest_test_default_role (id=2)" $
-      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.nwzjMI0YLvVGJQTeoCPEBsK983b__gxdpLXisBNaO2A" in
-      request methodPost "/rpc/get_current_user" [auth]
-        [json| {} |]
-         `shouldRespondWith` [json|"postgrest_test_default_role"|]
-          { matchStatus = 200
-          , matchHeaders = []
-          }
-
-    it "raises error (id=3)" $
-      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.OGxEJAf60NKZiTn-tIb2jy4rqKs_ZruLGWZ40TjrJsM" in
-      request methodPost "/rpc/get_current_user" [auth]
-        [json| {} |]
-         `shouldRespondWith` [json|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]
-          { matchStatus = 400
-          , matchHeaders = []
-          }
-
-  it "allows 'Bearer' and 'bearer' as authentication schemes" $ do
-    let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
-    request methodGet "/authors_only" [authHeader "Bearer" token] ""
-      `shouldRespondWith` 200
-    request methodGet "/authors_only" [authHeader "bearer" token] ""
-      `shouldRespondWith` 200
diff --git a/test/Feature/BinaryJwtSecretSpec.hs b/test/Feature/BinaryJwtSecretSpec.hs
deleted file mode 100644
--- a/test/Feature/BinaryJwtSecretSpec.hs
+++ /dev/null
@@ -1,21 +0,0 @@
-module Feature.BinaryJwtSecretSpec where
-
--- {{{ Imports
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-
-import Protolude
-import SpecHelper
--- }}}
-
-spec :: SpecWith ((), Application)
-spec = describe "server started with binary JWT secret" $
-
-  -- this test will stop working 9999999999s after the UNIX EPOCH
-  it "succeeds with jwt token encoded with a binary secret" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` 200
diff --git a/test/Feature/ConcurrentSpec.hs b/test/Feature/ConcurrentSpec.hs
deleted file mode 100644
--- a/test/Feature/ConcurrentSpec.hs
+++ /dev/null
@@ -1,52 +0,0 @@
-{-# LANGUAGE MultiParamTypeClasses #-}
-{-# LANGUAGE TypeFamilies          #-}
-{-# LANGUAGE UndecidableInstances  #-}
-{-# OPTIONS_GHC -fno-warn-orphans #-}
-module Feature.ConcurrentSpec where
-
-import Control.Concurrent.Async (mapConcurrently)
-import Network.Wai              (Application)
-
-import Control.Monad.Base
-import Control.Monad.Trans.Control
-
-import Network.Wai.Test        (Session)
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.Internal
-import Test.Hspec.Wai.JSON
-
-import Protolude hiding (get)
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "Querying in parallel" $
-    it "should not raise 'transaction in progress' error" $
-      raceTest 10 $
-        get "/fakefake"
-          `shouldRespondWith` [json|
-              { "hint": null,
-                "details":null,
-                "code":"42P01",
-                "message":"relation \"test.fakefake\" does not exist"
-              } |]
-          { matchStatus  = 404
-          , matchHeaders = []
-          }
-
-raceTest :: Int -> WaiExpectation st -> WaiExpectation st
-raceTest times = liftBaseDiscard go
- where
-  go test = void $ mapConcurrently (const test) [1..times]
-
-instance MonadBaseControl IO (WaiSession st) where
-  type StM (WaiSession st) a = StM Session a
-  liftBaseWith f = WaiSession $
-    liftBaseWith $ \runInBase ->
-      f $ \k -> runInBase (unWaiSession k)
-  restoreM = WaiSession . restoreM
-  {-# INLINE liftBaseWith #-}
-  {-# INLINE restoreM #-}
-
-instance MonadBase IO (WaiSession st) where
-  liftBase = liftIO
diff --git a/test/Feature/CorsSpec.hs b/test/Feature/CorsSpec.hs
deleted file mode 100644
--- a/test/Feature/CorsSpec.hs
+++ /dev/null
@@ -1,74 +0,0 @@
-module Feature.CorsSpec where
-
--- {{{ Imports
-import qualified Data.ByteString.Lazy as BL
-
-import Network.Wai      (Application)
-import Network.Wai.Test (SResponse (simpleBody, simpleHeaders))
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-
-import Protolude
-import SpecHelper
--- }}}
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "CORS" $ do
-    let preflightHeaders = [
-          ("Accept", "*/*"),
-          ("Origin", "http://example.com"),
-          ("Access-Control-Request-Method", "POST"),
-          ("Access-Control-Request-Headers", "Foo,Bar") ]
-    let normalCors = [
-          ("Host", "localhost:3000"),
-          ("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0"),
-          ("Origin", "http://localhost:8000"),
-          ("Accept", "text/csv, */*; q=0.01"),
-          ("Accept-Language", "en-US,en;q=0.5"),
-          ("Accept-Encoding", "gzip, deflate"),
-          ("Referer", "http://localhost:8000/"),
-          ("Connection", "keep-alive") ]
-
-    describe "preflight request" $ do
-      it "replies naively and permissively to preflight request" $ do
-        r <- request methodOptions "/items" preflightHeaders ""
-        liftIO $ do
-          let respHeaders = simpleHeaders r
-          respHeaders `shouldSatisfy` matchHeader
-            "Access-Control-Allow-Origin"
-            "http://example.com"
-          respHeaders `shouldSatisfy` matchHeader
-            "Access-Control-Allow-Credentials"
-            "true"
-          respHeaders `shouldSatisfy` matchHeader
-            "Access-Control-Allow-Methods"
-            "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"
-          respHeaders `shouldSatisfy` matchHeader
-            "Access-Control-Allow-Headers"
-            "Authentication, Foo, Bar, Accept, Accept-Language, Content-Language"
-          respHeaders `shouldSatisfy` matchHeader
-            "Access-Control-Max-Age"
-            "86400"
-
-      it "suppresses body in response" $ do
-        r <- request methodOptions "/" preflightHeaders ""
-        liftIO $ simpleBody r `shouldBe` ""
-
-    describe "regular request" $
-      it "exposes necesssary response headers" $ do
-        r <- request methodGet "/items" [("Origin", "http://example.com")] ""
-        liftIO $ simpleHeaders r `shouldSatisfy` matchHeader
-          "Access-Control-Expose-Headers"
-          "Content-Encoding, Content-Location, Content-Range, Content-Type, \
-            \Date, Location, Server, Transfer-Encoding, Range-Unit"
-
-    describe "postflight request" $
-      it "allows INFO body through even with CORS request headers present" $ do
-        r <- request methodOptions "/items" normalCors ""
-        liftIO $ do
-          simpleHeaders r `shouldSatisfy` matchHeader
-            "Access-Control-Allow-Origin" "\\*"
-          simpleBody r `shouldSatisfy` BL.null
diff --git a/test/Feature/DeleteSpec.hs b/test/Feature/DeleteSpec.hs
deleted file mode 100644
--- a/test/Feature/DeleteSpec.hs
+++ /dev/null
@@ -1,92 +0,0 @@
-module Feature.DeleteSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude hiding (get)
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "Deleting" $ do
-    context "existing record" $ do
-      it "succeeds with 204 and deletion count" $
-        request methodDelete "/items?id=eq.1" [] ""
-          `shouldRespondWith` ""
-          { matchStatus  = 204
-          , matchHeaders = ["Content-Range" <:> "*/*"]
-          }
-
-      it "returns the deleted item and count if requested" $
-        request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""
-          `shouldRespondWith` [json|[{"id":2}]|]
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "*/1"]
-          }
-
-      it "ignores ?select= when return not set or return=minimal" $ do
-        request methodDelete "/items?id=eq.3&select=id" [] ""
-          `shouldRespondWith` ""
-          { matchStatus  = 204
-          , matchHeaders = ["Content-Range" <:> "*/*"]
-          }
-        request methodDelete "/items?id=eq.3&select=id" [("Prefer", "return=minimal")] ""
-          `shouldRespondWith` ""
-          { matchStatus  = 204
-          , matchHeaders = ["Content-Range" <:> "*/*"]
-          }
-
-      it "returns the deleted item and shapes the response" $
-        request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
-          `shouldRespondWith` [json|[{"id":2,"name":"Two"}]|]
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "*/*"]
-          }
-
-      it "can rename and cast the selected columns" $
-        request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
-          `shouldRespondWith` [json|[{"ciId":"3","ciName":"Three"}]|]
-
-      it "can embed (parent) entities" $
-        request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] ""
-          `shouldRespondWith` [json|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "*/*"]
-          }
-
-    context "known route, no records matched" $
-      it "includes [] body if return=rep" $
-        request methodDelete "/items?id=eq.101"
-          [("Prefer", "return=representation")] ""
-          `shouldRespondWith` "[]"
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "*/*"]
-          }
-
-    context "totally unknown route" $
-      it "fails with 404" $
-        request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
-
-    context "table with limited privileges" $ do
-      it "fails deleting the row when return=representation and selecting all the columns" $
-        request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty
-            `shouldRespondWith` 401
-
-      it "succeeds deleting the row when return=representation and selecting only the privileged columns" $
-        request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]
-          [json| { "password": "passxyz" } |]
-            `shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]
-            { matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-      it "suceeds deleting the row with no explicit select when using return=minimal" $
-        request methodDelete "/app_users?id=eq.2" [("Prefer", "return=minimal")] mempty
-            `shouldRespondWith` 204
-
-      it "suceeds deleting the row with no explicit select by default" $
-        request methodDelete "/app_users?id=eq.3" [] mempty
-            `shouldRespondWith` 204
diff --git a/test/Feature/DisabledOpenApiSpec.hs b/test/Feature/DisabledOpenApiSpec.hs
deleted file mode 100644
--- a/test/Feature/DisabledOpenApiSpec.hs
+++ /dev/null
@@ -1,20 +0,0 @@
-module Feature.DisabledOpenApiSpec where
-
-import Network.HTTP.Types
-import Network.Wai        (Application)
-
-import Test.Hspec     hiding (pendingWith)
-import Test.Hspec.Wai
-
-import Protolude
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "Disabled OpenApi" $ do
-    it "does not accept application/openapi+json and responds with 415" $
-      request methodGet "/"
-        [("Accept","application/openapi+json")] "" `shouldRespondWith` 415
-
-    it "accepts application/json and responds with 404" $
-      request methodGet "/"
-        [("Accept","application/json")] "" `shouldRespondWith` 404
diff --git a/test/Feature/EmbedDisambiguationSpec.hs b/test/Feature/EmbedDisambiguationSpec.hs
deleted file mode 100644
--- a/test/Feature/EmbedDisambiguationSpec.hs
+++ /dev/null
@@ -1,440 +0,0 @@
-module Feature.EmbedDisambiguationSpec where
-
-import Network.Wai (Application)
-
-import Test.Hspec          hiding (pendingWith)
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "resource embedding disambiguation" $ do
-    context "ambiguous requests that give 300 Multiple Choices" $ do
-      it "errs when there's a table and view that point to the same fk" $
-        get "/message?select=id,body,sender(name,sent)" `shouldRespondWith`
-          [json|
-            {
-              "details": [
-                {
-                    "cardinality": "many-to-one",
-                    "relationship": "message_sender_fkey[sender][id]",
-                    "embedding": "message with person"
-                },
-                {
-                    "cardinality": "many-to-one",
-                    "relationship": "message_sender_fkey[sender][id]",
-                    "embedding": "message with person_detail"
-                }
-              ],
-              "hint": "Try changing 'sender' to one of the following: 'person!message_sender_fkey', 'person_detail!message_sender_fkey'. Find the desired relationship in the 'details' key.",
-              "message": "Could not embed because more than one relationship was found for 'message' and 'sender'"
-            }
-          |]
-          { matchStatus  = 300
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-      it "errs when there are o2m and m2m cardinalities to the target table" $
-        get "/sites?select=*,big_projects(*)" `shouldRespondWith`
-          [json|
-            {
-              "details": [
-                {
-                  "cardinality": "many-to-one",
-                  "relationship": "main_project[main_project_id][big_project_id]",
-                  "embedding": "sites with big_projects"
-                },
-                {
-                  "cardinality": "many-to-many",
-                  "relationship": "test.jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
-                  "embedding": "sites with big_projects"
-                },
-                {
-                  "cardinality": "many-to-many",
-                  "relationship": "test.main_jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
-                  "embedding": "sites with big_projects"
-                }
-              ],
-              "hint": "Try changing 'big_projects' to one of the following: 'big_projects!main_project', 'big_projects!jobs', 'big_projects!main_jobs'. Find the desired relationship in the 'details' key.",
-              "message": "Could not embed because more than one relationship was found for 'sites' and 'big_projects'"
-            }
-          |]
-          { matchStatus  = 300
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-      it "errs on an ambiguous embed that has a circular reference" $
-        get "/agents?select=*,departments(*)" `shouldRespondWith`
-          [json|
-            {
-              "details": [
-                {
-                    "cardinality": "many-to-one",
-                    "relationship": "agents_department_id_fkey[department_id][id]",
-                    "embedding": "agents with departments"
-                },
-                {
-                    "cardinality": "one-to-many",
-                    "relationship": "departments_head_id_fkey[id][head_id]",
-                    "embedding": "agents with departments"
-                }
-              ],
-              "hint": "Try changing 'departments' to one of the following: 'departments!agents_department_id_fkey', 'departments!departments_head_id_fkey'. Find the desired relationship in the 'details' key.",
-              "message": "Could not embed because more than one relationship was found for 'agents' and 'departments'"
-            }
-           |]
-          { matchStatus  = 300
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-      it "errs when there are more than two fks on a junction table(currently impossible to disambiguate, only choice is to split the table)" $
-        -- We have 4 possibilities for doing the junction JOIN here.
-        -- This could be solved by specifying two additional fks, like whatev_projects!fk1!fk2(*)
-        -- If the need arises this capability can be added later without causing a breaking change
-        get "/whatev_sites?select=*,whatev_projects(*)" `shouldRespondWith`
-          [json|
-            {
-              "details": [
-                {
-                  "cardinality": "many-to-many",
-                  "relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_1_fkey]",
-                  "embedding": "whatev_sites with whatev_projects"
-                },
-                {
-                  "cardinality": "many-to-many",
-                  "relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_2_fkey]",
-                  "embedding": "whatev_sites with whatev_projects"
-                },
-                {
-                  "cardinality": "many-to-many",
-                  "relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_1_fkey]",
-                  "embedding": "whatev_sites with whatev_projects"
-                },
-                {
-                  "cardinality": "many-to-many",
-                  "relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_2_fkey]",
-                  "embedding": "whatev_sites with whatev_projects"
-                }
-              ],
-              "hint": "Try changing 'whatev_projects' to one of the following: 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs'. Find the desired relationship in the 'details' key.",
-              "message": "Could not embed because more than one relationship was found for 'whatev_sites' and 'whatev_projects'"
-            }
-          |]
-          { matchStatus  = 300
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-    context "disambiguating requests with embed hints" $ do
-
-      context "using FK to specify the relationship" $ do
-        it "can embed by FK name" $
-          get "/projects?id=in.(1,3)&select=id,name,client(id,name)" `shouldRespondWith`
-            [json|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client":{"id":2,"name":"Apple"}}]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can embed by FK name and select the FK column at the same time" $
-          get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`
-            [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can embed parent with view!fk and grandparent by using fk" $
-          get "/tasks?id=eq.1&select=id,name,projects_view!project(id,name,client(id,name))" `shouldRespondWith`
-            [json|[{"id":1,"name":"Design w7","projects_view":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
-
-        it "can embed by using a composite FK name" $
-          get "/unit_workdays?select=unit_id,day,fst_shift(car_id,schedule(name)),snd_shift(camera_id,schedule(name))" `shouldRespondWith`
-            [json| [
-              {
-                "day": "2019-12-02",
-                "fst_shift": {
-                    "car_id": "CAR-349",
-                    "schedule": {
-                        "name": "morning"
-                    }
-                },
-                "snd_shift": {
-                    "camera_id": "CAM-123",
-                    "schedule": {
-                        "name": "night"
-                    }
-                },
-                "unit_id": 1
-              }
-            ] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "embeds by using two fks pointing to the same table" $
-          get "/orders?id=eq.1&select=id, name, billing(address), shipping(address)" `shouldRespondWith`
-            [json|[{"id":1,"name":"order 1","billing":{"address": "address 1"},"shipping":{"address": "address 2"}}]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "fails if the fk is not known" $
-          get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`
-            [json|{
-              "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
-              "message":"Could not find a relationship between message and person in the schema cache"}|]
-            { matchStatus = 400
-            , matchHeaders = [matchContentTypeJson] }
-
-        it "can request a parent with fk" $
-          get "/comments?select=content,user(name)" `shouldRespondWith`
-            [json|[ { "content": "Needs to be delivered ASAP", "user": { "name": "Angela Martin" } } ]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can request two parents with fks" $
-          get "/articleStars?select=createdAt,article(id),user(name)&limit=1"
-            `shouldRespondWith`
-              [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"id": 1},"user":{"name": "Angela Martin"}}]|]
-
-        it "can specify a view!fk" $
-          get "/message?select=id,body,sender:person_detail!message_sender_fkey(name,sent),recipient:person_detail!message_recipient_fkey(name,received)&id=lt.4" `shouldRespondWith`
-            [json|
-              [{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
-               {"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
-               {"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can specify a table!fk hint and request children 2 levels" $
-          get "/clients?id=eq.1&select=id,projects:projects!client(id,tasks(id))" `shouldRespondWith`
-            [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can disambiguate with the fk in case of an o2m and m2m relationship to the same table" $
-          get "/sites?select=name,main_project(name)&site_id=eq.1" `shouldRespondWith`
-            [json| [ { "name": "site 1", "main_project": { "name": "big project 1" } } ] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-      context "using the column name of the FK to specify the relationship" $ do
-        it "can embed by column" $
-          get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`
-            [json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can embed by column and select the column at the same time, if aliased" $
-          get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`
-            [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can embed parent by using view!column and grandparent by using the column" $
-          get "/tasks?id=eq.1&select=id,name,project:projects_view!project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
-            [json|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
-
-        it "can specify table!column" $
-          get "/message?select=id,body,sender:person!sender(name),recipient:person!recipient(name)&id=lt.4" `shouldRespondWith`
-            [json|
-              [{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
-               {"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
-               {"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "will embed using a column that has uppercase chars" $
-          get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
-            [json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "embeds by using two columns pointing to the same table" $
-          get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`
-            [json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can disambiguate with the column in case of an o2m and m2m relationship to the same table" $
-          get "/sites?select=name,main_project_id(name)&site_id=eq.1" `shouldRespondWith`
-            [json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-      context "using the junction to disambiguate the request" $
-        it "can specify the junction of an m2m relationship" $ do
-          get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
-            [json|
-              [
-                {
-                  "big_projects": [
-                    {
-                      "name": "big project 1"
-                    }
-                  ],
-                  "main_project_id": 1,
-                  "name": "site 1",
-                  "site_id": 1
-                },
-                {
-                  "big_projects": [
-                    {
-                      "name": "big project 1"
-                    },
-                    {
-                      "name": "big project 2"
-                    }
-                  ],
-                  "main_project_id": null,
-                  "name": "site 2",
-                  "site_id": 2
-                }
-              ]
-            |]
-          get "/sites?select=*,big_projects!main_jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
-            [json|
-              [
-                {
-                  "big_projects": [
-                    {
-                      "name": "big project 1"
-                    }
-                  ],
-                  "main_project_id": 1,
-                  "name": "site 1",
-                  "site_id": 1
-                },
-                {
-                  "big_projects": [],
-                  "main_project_id": null,
-                  "name": "site 2",
-                  "site_id": 2
-                }
-              ]
-            |]
-            { matchHeaders = [matchContentTypeJson] }
-
-      context "using a FK column and a FK to specify the relationship" $
-        it "embeds by using a column and a fk pointing to the same table" $
-          get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping(id)" `shouldRespondWith`
-            [json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping":{"id":2}}]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-    context "tables with self reference foreign keys" $ do
-      context "one self reference foreign key" $ do
-        it "embeds parents recursively" $
-          get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`
-            [json|[
-              { "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },
-              { "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }
-            ]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "embeds children recursively" $
-          get "/family_tree?id=eq.1&select=id,name, children:family_tree!parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
-            [json|[{
-              "id": "1", "name": "Parental Unit", "children": [
-                { "id": "2", "name": "Kid One", "children": [ { "id": "4", "name": "Grandkid One" } ] },
-                { "id": "3", "name": "Kid Two", "children": [ { "id": "5", "name": "Grandkid Two" } ] }
-              ]
-            }]|] { matchHeaders = [matchContentTypeJson] }
-
-        it "embeds parent and then embeds children" $
-          get "/family_tree?id=eq.2&select=id,name,parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
-            [json|[{
-              "id": "2", "name": "Kid One", "parent": {
-                "id": "1", "name": "Parental Unit", "children": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
-              }
-            }]|] { matchHeaders = [matchContentTypeJson] }
-
-      context "two self reference foreign keys" $ do
-        it "embeds parents" $
-          get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
-            [json|[{
-              "id": 3, "name": "Acme",
-              "referee": {
-                "id": 1,
-                "name": "Referee Org"
-              },
-              "auditor": {
-                "id": 2,
-                "name": "Auditor Org"
-              }
-            }]|] { matchHeaders = [matchContentTypeJson] }
-
-        it "embeds children" $ do
-          get "/organizations?select=id,name,refereeds:organizations!referee(id,name)&id=eq.1" `shouldRespondWith`
-            [json|[{
-              "id": 1, "name": "Referee Org",
-              "refereeds": [
-                {
-                  "id": 3,
-                  "name": "Acme"
-                },
-                {
-                  "id": 4,
-                  "name": "Umbrella"
-                }
-              ]
-            }]|] { matchHeaders = [matchContentTypeJson] }
-          get "/organizations?select=id,name,auditees:organizations!auditor(id,name)&id=eq.2" `shouldRespondWith`
-            [json|[{
-              "id": 2, "name": "Auditor Org",
-              "auditees": [
-                {
-                  "id": 3,
-                  "name": "Acme"
-                },
-                {
-                  "id": 4,
-                  "name": "Umbrella"
-                }
-              ]
-            }]|] { matchHeaders = [matchContentTypeJson] }
-
-        it "embeds other relations(manager) besides the self reference" $ do
-          get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`
-            [json|[{
-              "name":"Cyberdyne",
-              "manager":{"name":"Cyberdyne Manager"},
-              "referee":{
-                "name":"Acme",
-                "manager":{"name":"Acme Manager"},
-                "auditor":{
-                  "name":"Auditor Org",
-                  "manager":{"name":"Auditor Manager"}}},
-              "auditor":{
-                "name":"Umbrella",
-                "manager":{"name":"Umbrella Manager"},
-                "referee":{
-                  "name":"Referee Org",
-                  "manager":{"name":"Referee Manager"}}}
-            }]|] { matchHeaders = [matchContentTypeJson] }
-
-          get "/organizations?select=name,manager(name),auditees:organizations!auditor(name,manager(name),refereeds:organizations!referee(name,manager(name)))&id=eq.2" `shouldRespondWith`
-            [json|[{
-              "name":"Auditor Org",
-              "manager":{"name":"Auditor Manager"},
-              "auditees":[
-                {"name":"Acme",
-                 "manager":{"name":"Acme Manager"},
-                 "refereeds":[
-                   {"name":"Cyberdyne",
-                    "manager":{"name":"Cyberdyne Manager"}},
-                   {"name":"Oscorp",
-                    "manager":{"name":"Oscorp Manager"}}]},
-                {"name":"Umbrella",
-                 "manager":{"name":"Umbrella Manager"},
-                 "refereeds":[]}]
-            }]|] { matchHeaders = [matchContentTypeJson] }
-
-    context "m2m embed when there's a junction in an internal schema" $ do
-      -- https://github.com/PostgREST/postgrest/issues/1736
-      it "works with no ambiguity when there's an exposed view of the junction" $ do
-        get "/screens?select=labels(name)" `shouldRespondWith`
-          [json|[{"labels":[{"name":"fruit"}]}, {"labels":[{"name":"vehicles"}]}, {"labels":[{"name":"vehicles"}, {"name":"fruit"}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/actors?select=*,films(*)" `shouldRespondWith`
-          [json|[ {"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]},
-                  {"id":2,"name":"mary","films":[{"id":2001,"title":"odyssée de l'espace"}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-      it "doesn't work if the junction is only internal" $
-        get "/end_1?select=end_2(*)" `shouldRespondWith`
-          [json|{
-            "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
-            "message":"Could not find a relationship between end_1 and end_2 in the schema cache"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson] }
-      it "shouldn't try to embed if the private junction has an exposed homonym" $
-        -- ensures the "invalid reference to FROM-clause entry for table "rollen" error doesn't happen.
-        -- Ref: https://github.com/PostgREST/postgrest/issues/1587#issuecomment-734995669
-        get "/schauspieler?select=filme(*)" `shouldRespondWith`
-          [json|{
-            "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
-            "message":"Could not find a relationship between schauspieler and filme in the schema cache"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson] }
diff --git a/test/Feature/EmbedInnerJoinSpec.hs b/test/Feature/EmbedInnerJoinSpec.hs
deleted file mode 100644
--- a/test/Feature/EmbedInnerJoinSpec.hs
+++ /dev/null
@@ -1,356 +0,0 @@
-module Feature.EmbedInnerJoinSpec where
-
-import Network.HTTP.Types
-import Network.Wai        (Application)
-
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "Embedding with an inner join" $ do
-    context "many-to-one relationships" $ do
-      it "ignores null embeddings while the default left join doesn't" $ do
-        get "/projects?select=id,clients!inner(id)" `shouldRespondWith`
-          [json|[
-            {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
-            {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/projects?select=id,clients!left(id)" `shouldRespondWith`
-          [json|[
-            {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
-            {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},
-            {"id":5,"clients":null}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/projects?select=id,clients!inner(id)" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-3/4" ]
-          }
-
-      it "filters source tables when the embedded table is filtered" $ do
-        get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
-          [json|[
-            {"id":1,"clients":{"id":1}},
-            {"id":2,"clients":{"id":1}}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/projects?select=id,clients!inner(id)&clients.id=eq.2" `shouldRespondWith`
-          [json|[
-            {"id":3,"clients":{"id":2}},
-            {"id":4,"clients":{"id":2}}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith`
-          [json|[]|]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/projects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-1/2" ]
-          }
-
-      it "filters source tables when a two levels below embedded table is filtered" $ do
-        get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" `shouldRespondWith`
-          [json|[
-            {"id":1,"projects":{"id":1,"clients":{"id":1}}},
-            {"id":2,"projects":{"id":1,"clients":{"id":1}}},
-            {"id":3,"projects":{"id":2,"clients":{"id":1}}},
-            {"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
-          [json|[
-            {"id":5,"projects":{"id":3,"clients":{"id":2}}},
-            {"id":6,"projects":{"id":3,"clients":{"id":2}}},
-            {"id":7,"projects":{"id":4,"clients":{"id":2}}},
-            {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-3/4" ]
-          }
-
-      it "only affects the source table rows if his direct embedding is an inner join" $ do
-        get "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
-          [json|[
-            {"id":1,"projects":null},
-            {"id":2,"projects":null},
-            {"id":3,"projects":null},
-            {"id":4,"projects":null},
-            {"id":5,"projects":{"id":3,"clients":{"id":2}}},
-            {"id":6,"projects":{"id":3,"clients":{"id":2}}},
-            {"id":7,"projects":{"id":4,"clients":{"id":2}}},
-            {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-7/8" ]
-          }
-
-      it "works with views" $ do
-        get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith`
-          [json| [{"title":"1984","authors":{"name":"George Orwell"}}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-0/1" ]
-          }
-
-    context "one-to-many relationships" $ do
-      it "ignores empty array embeddings while the default left join doesn't" $ do
-        get "/entities?select=id,child_entities!inner(id)" `shouldRespondWith`
-          [json|[
-            {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
-            {"id":2,"child_entities":[{"id":3}, {"id":6}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/entities?select=id,child_entities!left(id)" `shouldRespondWith`
-          [json| [
-            {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
-            {"id":2,"child_entities":[{"id":3}, {"id":6}]},
-            {"id":3,"child_entities":[]},
-            {"id":4,"child_entities":[]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/entities?select=id,child_entities!inner(id)" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-1/2" ]
-          }
-
-      it "filters source tables when the embedded table is filtered" $ do
-        get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" `shouldRespondWith`
-          [json|[{"id":1,"child_entities":[{"id":1}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.3" `shouldRespondWith`
-          [json|[{"id":2,"child_entities":[{"id":3}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith`
-          [json|[]|]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-0/1" ]
-          }
-
-      it "filters source tables when a two levels below embedded table is filtered" $ do
-        get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)"
-          `shouldRespondWith`
-          [json|[
-            {
-              "id": 1,
-              "child_entities": [
-                { "id": 1, "grandchild_entities": [ { "id": 1 } ] },
-                { "id": 2, "grandchild_entities": [ { "id": 5 } ] }]
-            }
-          ]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
-          [json|[
-            {
-              "id": 1,
-              "child_entities": [
-                { "id": 1, "grandchild_entities": [ { "id": 2 } ] } ]
-            }
-          ]|]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-0/1" ]
-          }
-
-      it "only affects the source table rows if his direct embedding is an inner join" $ do
-        get "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
-          [json|[
-            {
-              "id": 1,
-              "child_entities": [
-                { "id": 1, "grandchild_entities": [ { "id": 2 } ] },
-                { "id": 2, "grandchild_entities": [] },
-                { "id": 4, "grandchild_entities": [] },
-                { "id": 5, "grandchild_entities": [] } ]
-            },
-            {
-              "id": 2,
-              "child_entities": [
-                { "id": 3, "grandchild_entities": [] },
-                { "id": 6, "grandchild_entities": [] } ]
-            }
-          ]|]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-1/2" ]
-          }
-
-      it "works with views" $ do
-        get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith`
-          [json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/authors?select=*,books!inner(*)&books.title=eq.1984" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-0/1" ]
-          }
-
-    context "many-to-many relationships" $ do
-      it "ignores empty array embeddings while the default left join doesn't" $ do
-        get "/products?select=id,suppliers!inner(id)" `shouldRespondWith`
-          [json| [
-            {"id":1,"suppliers":[{"id":1}, {"id":2}]},
-            {"id":2,"suppliers":[{"id":1}, {"id":3}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/products?select=id,suppliers!left(id)" `shouldRespondWith`
-          [json| [
-            {"id":1,"suppliers":[{"id":1}, {"id":2}]},
-            {"id":2,"suppliers":[{"id":1}, {"id":3}]},
-            {"id":3,"suppliers":[]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/products?select=id,suppliers!inner(id)" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-1/2" ]
-          }
-
-      it "filters source tables when the embedded table is filtered" $ do
-        get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" `shouldRespondWith`
-          [json| [{"id":1,"suppliers":[{"id":2}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.3" `shouldRespondWith`
-          [json| [{"id":2,"suppliers":[{"id":3}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith`
-          [json| [] |]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-0/1" ]
-          }
-
-      it "filters source tables when a two levels below embedded table is filtered" $ do
-        get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3"
-          `shouldRespondWith`
-          [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.4"
-          `shouldRespondWith`
-          [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-0/1" ]
-          }
-
-      it "only affects the source table rows if his direct embedding is an inner join" $ do
-        get "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" `shouldRespondWith`
-          [json|[
-            {"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]},
-            {"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-1/2" ]
-          }
-
-      it "works with views" $ do
-        get "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" `shouldRespondWith`
-          [json| [{"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith`
-          [json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        request methodHead "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" [("Prefer", "count=exact")] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson
-                           , "Content-Range" <:> "0-0/1" ]
-          }
-
-    it "works with m2o and m2m relationships combined" $ do
-      get "/projects?select=name,clients!inner(name),users!inner(name)" `shouldRespondWith`
-        [json| [
-          {"name":"Windows 7","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}, {"name":"Dwight Schrute"}]},
-          {"name":"Windows 10","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}]},
-          {"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]},
-          {"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-      request methodHead "/projects?select=name,clients!inner(name),users!inner(name)" [("Prefer", "count=exact")] mempty
-        `shouldRespondWith` ""
-        { matchStatus  = 200
-        , matchHeaders = [ matchContentTypeJson
-                         , "Content-Range" <:> "0-3/4" ]
-        }
-
-    it "works with rpc" $ do
-      get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
-        [json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      request methodHead "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
-        `shouldRespondWith` ""
-        { matchStatus  = 200
-        , matchHeaders = [ matchContentTypeJson
-                         , "Content-Range" <:> "0-1/2" ]
-        }
-
-    it "works when using hints" $ do
-      get "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" `shouldRespondWith`
-        [json| [{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith`
-        [json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      request methodHead "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" [("Prefer", "count=exact")] mempty
-        `shouldRespondWith` ""
-        { matchStatus  = 200
-        , matchHeaders = [ matchContentTypeJson
-                         , "Content-Range" <:> "0-1/2" ]
-        }
-
-    it "works with many one-to-many relationships" $ do
-      -- https://github.com/PostgREST/postgrest/issues/1977
-      get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" `shouldRespondWith`
-        [json|[
-          {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}, {"name":"Wilma Wellers"}],"clientinfo":[{"other":"123 Main St"}]},
-          {"id":2,"name":"Target", "contact":[{"name":"Tabby Targo"}],"clientinfo":[{"other":"456 South 3rd St"}]},
-          {"id":3,"name":"Big Lots","contact":[{"name":"Bobby Bots"}, {"name":"Bonnie Bits"}, {"name":"Billy Boats"}],"clientinfo":[{"other":"789 Palm Tree Ln"}]}
-        ]|]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&contact.name=eq.Wally%20Walton" `shouldRespondWith`
-        [json|[
-          {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}],"clientinfo":[{"other":"123 Main St"}]}
-        ]|]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&clientinfo.other=eq.456%20South%203rd%20St" `shouldRespondWith`
-        [json|[
-          {"id":2,"name":"Target","clientinfo":[{"other":"456 South 3rd St"}],"contact":[{"name":"Tabby Targo"}]}
-        ]|]
-        { matchHeaders = [matchContentTypeJson] }
-      request methodHead "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" [("Prefer", "count=exact")] mempty
-        `shouldRespondWith` ""
-        { matchStatus  = 200
-        , matchHeaders = [ matchContentTypeJson
-                         , "Content-Range" <:> "0-2/3" ]
-        }
diff --git a/test/Feature/ExtraSearchPathSpec.hs b/test/Feature/ExtraSearchPathSpec.hs
deleted file mode 100644
--- a/test/Feature/ExtraSearchPathSpec.hs
+++ /dev/null
@@ -1,39 +0,0 @@
-module Feature.ExtraSearchPathSpec where
-
-import Network.HTTP.Types
-import Network.Wai         (Application)
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec = describe "extra search path" $ do
-
-  it "finds the ltree <@ operator on the public schema" $
-    request methodGet "/ltree_sample?path=cd.Top.Science.Astronomy" [] ""
-      `shouldRespondWith` [json|[
-        {"path":"Top.Science.Astronomy"},
-        {"path":"Top.Science.Astronomy.Astrophysics"},
-        {"path":"Top.Science.Astronomy.Cosmology"}]|]
-      { matchHeaders = [matchContentTypeJson] }
-
-  it "finds the ltree nlevel function on the public schema, used through a computed column" $
-    request methodGet "/ltree_sample?select=number_of_labels&path=eq.Top.Science" [] ""
-      `shouldRespondWith` [json|[{"number_of_labels":2}]|]
-      { matchHeaders = [matchContentTypeJson] }
-
-  it "finds the isn = operator on the extensions schema" $
-    request methodGet "/isn_sample?id=eq.978-0-393-04002-9&select=name" [] ""
-      `shouldRespondWith` [json|[{"name":"Mathematics: From the Birth of Numbers"}]|]
-      { matchHeaders = [matchContentTypeJson] }
-
-  it "finds the isn is_valid function on the extensions schema" $
-    request methodGet "/rpc/is_valid_isbn?input=978-0-393-04002-9" [] ""
-      `shouldRespondWith` [json|true|]
-      { matchHeaders = [matchContentTypeJson] }
-
-  it "can detect fk relations through multiple views recursively when middle views are in extra search path" $
-    get "/consumers_extra_view?select=*,orders_view(*)" `shouldRespondWith` 200
diff --git a/test/Feature/HtmlRawOutputSpec.hs b/test/Feature/HtmlRawOutputSpec.hs
deleted file mode 100644
--- a/test/Feature/HtmlRawOutputSpec.hs
+++ /dev/null
@@ -1,30 +0,0 @@
-module Feature.HtmlRawOutputSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec         hiding (pendingWith)
-import Test.Hspec.Wai
-import Text.Heredoc
-
-import Protolude  hiding (get)
-import SpecHelper (acceptHdrs)
-
-spec :: SpecWith ((), Application)
-spec = describe "When raw-media-types is set to \"text/html\"" $
-  it "can get raw output with Accept: text/html" $
-    request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
-      `shouldRespondWith`
-      [str|
-          |<html>
-          |  <head>
-          |    <title>PostgREST</title>
-          |  </head>
-          |  <body>
-          |    <h1>Welcome to PostgREST</h1>
-          |  </body>
-          |</html>
-          |]
-      { matchStatus = 200
-      , matchHeaders = ["Content-Type" <:> "text/html"]
-      }
diff --git a/test/Feature/IgnorePrivOpenApiSpec.hs b/test/Feature/IgnorePrivOpenApiSpec.hs
deleted file mode 100644
--- a/test/Feature/IgnorePrivOpenApiSpec.hs
+++ /dev/null
@@ -1,76 +0,0 @@
-module Feature.IgnorePrivOpenApiSpec where
-
-import Control.Lens ((^?))
-
-import Data.Aeson.Lens
-import Data.Aeson.QQ
-
-import Network.HTTP.Types
-import Network.Wai        (Application)
-import Network.Wai.Test   (SResponse (..))
-
-import Test.Hspec     hiding (pendingWith)
-import Test.Hspec.Wai
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec = describe "OpenAPI Ignore Privileges" $ do
-  it "root path returns a valid openapi spec" $ do
-    validateOpenApiResponse [("Accept", "application/openapi+json")]
-    request methodHead "/" (acceptHdrs "application/openapi+json") ""
-      `shouldRespondWith` "" { matchStatus  = 200 }
-
-  describe "table" $ do
-
-    it "includes privileged table even if user does not have permission" $ do
-      r <- simpleBody <$> get "/"
-      let tableTag = r ^? key "paths" . key "/authors_only"
-                     . key "post" . key "tags"
-                     . nth 0
-
-      liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]
-
-    it "only includes tables that belong to another schema if the Accept-Profile header is used" $ do
-      r1 <- simpleBody <$> get "/"
-      let tableKey1 = r1 ^? key "paths" . key "/children"
-
-      liftIO $ tableKey1 `shouldBe` Nothing
-
-      r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] ""
-      let tableKey2 = r2 ^? key "paths" . key "/children"
-
-      liftIO $ tableKey2 `shouldNotBe` Nothing
-
-    it "includes comments on tables" $ do
-      r <- simpleBody <$> get "/"
-
-      let grandChildGet s = key "paths" . key "/grandchild_entities" . key "get" . key s
-          grandChildGetSummary = r ^? grandChildGet "summary"
-          grandChildGetDescription = r ^? grandChildGet "description"
-
-      liftIO $ do
-        grandChildGetSummary `shouldBe` Just "grandchild_entities summary"
-        grandChildGetDescription `shouldBe` Just "grandchild_entities description\nthat spans\nmultiple lines"
-
-  describe "RPC" $ do
-
-    it "includes privileged function even if user does not have permission" $ do
-      r <- simpleBody <$> get "/"
-      let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"
-                    . key "post" . key "tags"
-                    . nth 0
-
-      liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]
-
-    it "only includes functions that belong to another schema if the Accept-Profile header is used" $ do
-      r1 <- simpleBody <$> get "/"
-      let funcKey1 = r1 ^? key "paths" . key "/rpc/get_parents_below"
-
-      liftIO $ funcKey1 `shouldBe` Nothing
-
-      r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] ""
-      let funcKey2 = r2 ^? key "paths" . key "/rpc/get_parents_below"
-
-      liftIO $ funcKey2 `shouldNotBe` Nothing
diff --git a/test/Feature/InsertSpec.hs b/test/Feature/InsertSpec.hs
deleted file mode 100644
--- a/test/Feature/InsertSpec.hs
+++ /dev/null
@@ -1,558 +0,0 @@
-module Feature.InsertSpec where
-
-import Data.List              (lookup)
-import Network.Wai            (Application)
-import Network.Wai.Test       (SResponse (simpleHeaders))
-import Test.Hspec             hiding (pendingWith)
-import Test.Hspec.Wai.Matcher (bodyEquals)
-
-import Network.HTTP.Types
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-import Text.Heredoc
-
-import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
-                                   pgVersion112, pgVersion130)
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: PgVersion -> SpecWith ((), Application)
-spec actualPgVersion = do
-  describe "Posting new record" $ do
-    context "disparate json types" $ do
-      it "accepts disparate json types" $ do
-        post "/menagerie"
-          [json| {
-            "integer": 13, "double": 3.14159, "varchar": "testing!"
-          , "boolean": false, "date": "1900-01-01", "money": "$3.99"
-          , "enum": "foo"
-          } |] `shouldRespondWith` ""
-          { matchStatus  = 201
-            -- should not have content type set when body is empty
-          , matchHeaders = [matchHeaderAbsent hContentType]
-          }
-
-      it "filters columns in result using &select" $
-        request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=representation")]
-          [json| [{
-            "integer": 14, "double": 3.14159, "varchar": "testing!"
-          , "boolean": false, "date": "1900-01-01", "money": "$3.99"
-          , "enum": "foo"
-          }] |] `shouldRespondWith` [json|[{"integer":14,"varchar":"testing!"}]|]
-          { matchStatus  = 201
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-      it "ignores &select when return not set or using return=minimal" $ do
-        request methodPost "/menagerie?select=integer,varchar" []
-          [json| [{
-            "integer": 15, "double": 3.14159, "varchar": "testing!"
-          , "boolean": false, "date": "1900-01-01", "money": "$3.99"
-          , "enum": "foo"
-          }] |] `shouldRespondWith` ""
-          { matchStatus  = 201 }
-        request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=minimal")]
-          [json| [{
-            "integer": 16, "double": 3.14159, "varchar": "testing!"
-          , "boolean": false, "date": "1900-01-01", "money": "$3.99"
-          , "enum": "foo"
-          }] |] `shouldRespondWith` ""
-          { matchStatus  = 201 }
-
-    context "non uniform json array" $ do
-      it "rejects json array that isn't exclusivily composed of objects" $
-        post "/articles"
-             [json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |]
-        `shouldRespondWith`
-             [json| {"message":"All object keys must match"} |]
-             { matchStatus  = 400
-             , matchHeaders = [matchContentTypeJson]
-             }
-
-      it "rejects json array that has objects with different keys" $
-        post "/articles"
-             [json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]
-        `shouldRespondWith`
-             [json| {"message":"All object keys must match"} |]
-             { matchStatus  = 400
-             , matchHeaders = [matchContentTypeJson]
-             }
-
-    context "requesting full representation" $ do
-      it "includes related data after insert" $
-        request methodPost "/projects?select=id,name,clients(id,name)"
-                [("Prefer", "return=representation"), ("Prefer", "count=exact")]
-          [json|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [json|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]
-          { matchStatus  = 201
-          , matchHeaders = [ matchContentTypeJson
-                           , "Location" <:> "/projects?id=eq.6"
-                           , "Content-Range" <:> "*/1" ]
-          }
-
-      it "can rename and cast the selected columns" $
-        request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"
-                [("Prefer", "return=representation")]
-          [json|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith`
-          [json|[{"pId":"7","pName":"New Project","cId":"2"}]|]
-          { matchStatus  = 201
-          , matchHeaders = [ matchContentTypeJson
-                           , "Location" <:> "/projects?id=eq.7"
-                           , "Content-Range" <:> "*/*" ]
-          }
-
-      it "should not throw and return location header when selecting without PK" $
-        request methodPost "/projects?select=name,client_id" [("Prefer", "return=representation")]
-          [json|{"id":10,"name":"New Project","client_id":2}|] `shouldRespondWith`
-          [json|[{"name":"New Project","client_id":2}]|]
-          { matchStatus  = 201
-          , matchHeaders = [ matchContentTypeJson
-                           , "Location" <:> "/projects?id=eq.10"
-                           , "Content-Range" <:> "*/*" ]
-          }
-
-    context "requesting headers only representation" $ do
-      it "should not throw and return location header when selecting without PK" $
-        request methodPost "/projects?select=name,client_id" [("Prefer", "return=headers-only")]
-          [json|{"id":11,"name":"New Project","client_id":2}|] `shouldRespondWith` ""
-          { matchStatus  = 201
-          , matchHeaders = [ "Location" <:> "/projects?id=eq.11"
-                           , "Content-Range" <:> "*/*" ]
-          }
-
-      when (actualPgVersion >= pgVersion110) $
-        it "should not throw and return location header for partitioned tables when selecting without PK" $
-          request methodPost "/car_models" [("Prefer", "return=headers-only")]
-            [json|{"name":"Enzo","year":2021}|] `shouldRespondWith` ""
-            { matchStatus  = 201
-            , matchHeaders = [ "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021"
-                             , "Content-Range" <:> "*/*" ]
-            }
-
-    context "requesting no representation" $
-      it "should not throw and return no location header when selecting without PK" $
-        request methodPost "/projects?select=name,client_id" []
-          [json|{"id":12,"name":"New Project","client_id":2}|] `shouldRespondWith` ""
-          { matchStatus  = 201
-          , matchHeaders = [ matchHeaderAbsent hLocation ]
-          }
-
-    context "from an html form" $
-      it "accepts disparate json types" $ do
-        request methodPost "/menagerie"
-            [("Content-Type", "application/x-www-form-urlencoded")]
-             ("integer=7&double=2.71828&varchar=forms+are+fun&" <>
-              "boolean=false&date=1900-01-01&money=$3.99&enum=foo")
-          `shouldRespondWith`
-            ""
-            { matchStatus = 201 }
-
-    context "with no pk supplied" $ do
-      context "into a table with auto-incrementing pk" $
-        it "succeeds with 201 and location header" $ do
-          -- reset pk sequence first to make test repeatable
-          request methodPost "/rpc/reset_sequence"
-              [("Prefer", "tx=commit")]
-              [json|{"name": "auto_incrementing_pk_id_seq", "value": 2}|]
-            `shouldRespondWith`
-              [json|""|]
-
-          request methodPost "/auto_incrementing_pk" [("Prefer", "return=headers-only")]
-              [json| { "non_nullable_string":"not null"} |]
-            `shouldRespondWith`
-              ""
-              { matchStatus  = 201
-              , matchHeaders = [ "Location" <:> "/auto_incrementing_pk?id=eq.2" ]
-              }
-
-      context "into a table with simple pk" $
-        it "fails with 400 and error" $
-          post "/simple_pk" [json| { "extra":"foo"} |]
-          `shouldRespondWith`
-          (if actualPgVersion >= pgVersion130 then
-            [json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" of relation \"simple_pk\" violates not-null constraint"}|]
-           else
-            [json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]
-          )
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-      context "into a table with no pk" $ do
-        it "succeeds with 201 but no location header" $ do
-          post "/no_pk" [json| { "a":"foo", "b":"bar" } |]
-            `shouldRespondWith`
-              ""
-              { matchStatus  = 201
-              , matchHeaders = [matchHeaderAbsent hLocation]
-              }
-
-        it "returns full details of inserted record if asked" $ do
-          request methodPost "/no_pk"
-              [("Prefer", "return=representation")]
-              [json| { "a":"bar", "b":"baz" } |]
-            `shouldRespondWith`
-              [json| [{ "a":"bar", "b":"baz" }] |]
-              { matchStatus  = 201
-              , matchHeaders = [matchHeaderAbsent hLocation]
-              }
-
-        it "returns empty array when no items inserted, and return=rep" $ do
-          request methodPost "/no_pk"
-              [("Prefer", "return=representation")]
-              [json| [] |]
-            `shouldRespondWith`
-              [json| [] |]
-              { matchStatus = 201 }
-
-        it "can post nulls" $ do
-          request methodPost "/no_pk"
-              [("Prefer", "return=representation")]
-              [json| { "a":null, "b":"foo" } |]
-            `shouldRespondWith`
-              [json| [{ "a":null, "b":"foo" }] |]
-              { matchStatus  = 201
-              , matchHeaders = [matchHeaderAbsent hLocation]
-              }
-
-    context "with compound pk supplied" $
-      it "builds response location header appropriately" $ do
-        request methodPost "/compound_pk"
-            [("Prefer", "return=representation")]
-            [json| { "k1":12, "k2":"Rock & R+ll" } |]
-          `shouldRespondWith`
-            [json|[ { "k1":12, "k2":"Rock & R+ll", "extra": null } ]|]
-            { matchStatus  = 201
-            , matchHeaders = [ "Location" <:> "/compound_pk?k1=eq.12&k2=eq.Rock%20%26%20R%2Bll" ]
-            }
-
-    context "with bulk insert" $
-      it "returns 201 but no location header" $ do
-        let bulkData = [json| [ {"k1":21, "k2":"hello world"}
-                              , {"k1":22, "k2":"bye for now"}]
-                            |]
-        request methodPost "/compound_pk" [] bulkData
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 201
-            , matchHeaders = [matchHeaderAbsent hLocation]
-            }
-
-    context "with invalid json payload" $
-      it "fails with 400 and error" $
-        post "/simple_pk" "}{ x = 2"
-        `shouldRespondWith`
-        [json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
-        { matchStatus  = 400
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-    context "with no payload" $
-      it "fails with 400 and error" $
-        post "/simple_pk" ""
-        `shouldRespondWith`
-        [json|{"message":"Error in $: not enough input"}|]
-        { matchStatus  = 400
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-    context "with valid json payload" $
-      it "succeeds and returns 201 created" $
-        post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 201
-
-    context "attempting to insert a row with the same primary key" $
-      it "fails returning a 409 Conflict" $
-        post "/simple_pk"
-            [json| { "k":"xyyx", "extra":"e1" } |]
-          `shouldRespondWith`
-            [json|{"hint":null,"details":"Key (k)=(xyyx) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"simple_pk_pkey\""}|]
-            { matchStatus  = 409 }
-
-    context "attempting to insert a row with conflicting unique constraint" $
-      it "fails returning a 409 Conflict" $
-        post "/withUnique"  [json| { "uni":"nodup", "extra":"e2" } |] `shouldRespondWith` 409
-
-    context "jsonb" $ do
-      it "serializes nested object" $ do
-        let inserted = [json| { "data": { "foo":"bar" } } |]
-        request methodPost "/json_table"
-                     [("Prefer", "return=representation")]
-                     inserted
-          `shouldRespondWith` [json|[{"data":{"foo":"bar"}}]|]
-          { matchStatus  = 201
-          }
-
-      it "serializes nested array" $ do
-        let inserted = [json| { "data": [1,2,3] } |]
-        request methodPost "/json_table"
-                     [("Prefer", "return=representation")]
-                     inserted
-          `shouldRespondWith` [json|[{"data":[1,2,3]}]|]
-          { matchStatus  = 201
-          }
-
-    context "empty objects" $ do
-      it "successfully inserts a row with all-default columns" $ do
-        post "/items" "{}" `shouldRespondWith` ""
-          { matchStatus  = 201
-          , matchHeaders = []
-          }
-        post "/items" "[{}]" `shouldRespondWith` ""
-          { matchStatus  = 201
-          , matchHeaders = []
-          }
-
-      it "successfully inserts two rows with all-default columns" $
-        post "/items" "[{}, {}]" `shouldRespondWith` ""
-          { matchStatus  = 201
-          , matchHeaders = []
-          }
-
-      it "successfully inserts a row with all-default columns with prefer=rep" $ do
-        -- reset pk sequence first to make test repeatable
-        request methodPost "/rpc/reset_sequence"
-            [("Prefer", "tx=commit")]
-            [json|{"name": "items2_id_seq", "value": 20}|]
-          `shouldRespondWith`
-            [json|""|]
-
-        request methodPost "/items2"
-            [("Prefer", "return=representation")]
-            [json|{}|]
-          `shouldRespondWith`
-            [json|[{ id: 20 }]|]
-            { matchStatus  = 201 }
-
-      it "successfully inserts a row with all-default columns with prefer=rep and &select=" $ do
-        -- reset pk sequence first to make test repeatable
-        request methodPost "/rpc/reset_sequence"
-            [("Prefer", "tx=commit")]
-            [json|{"name": "items3_id_seq", "value": 20}|]
-          `shouldRespondWith`
-            [json|""|]
-
-        request methodPost "/items3?select=id"
-            [("Prefer", "return=representation")]
-            [json|{}|]
-          `shouldRespondWith` [json|[{ id: 20 }]|]
-            { matchStatus  = 201 }
-
-    context "POST with ?columns parameter" $ do
-      it "ignores json keys not included in ?columns" $ do
-        request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")]
-          [json| {"id": 200, "body": "xxx", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`
-          [json|[{"id": 200, "body": "xxx", "owner": "postgrest_test_anonymous"}]|]
-          { matchStatus  = 201
-          , matchHeaders = [] }
-        request methodPost "/articles?columns=id,body&select=id,body" [("Prefer", "return=representation")]
-          [json| [
-            {"id": 201, "body": "yyy", "smth": "here", "other": "stuff", "fake_id": 13},
-            {"id": 202, "body": "zzz", "garbage": "%%$&", "kkk": "jjj"},
-            {"id": 203, "body": "aaa", "hey": "ho"} ]|] `shouldRespondWith`
-          [json|[
-            {"id": 201, "body": "yyy"},
-            {"id": 202, "body": "zzz"},
-            {"id": 203, "body": "aaa"} ]|]
-          { matchStatus  = 201
-          , matchHeaders = [] }
-
-      -- TODO parse columns error message needs to be improved
-      it "disallows blank ?columns" $
-        post "/articles?columns="
-          [json|[
-            {"id": 204, "body": "yyy"},
-            {"id": 205, "body": "zzz"}]|]
-          `shouldRespondWith`
-          [json|  {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)"} |]
-          { matchStatus  = 400
-          , matchHeaders = []
-          }
-
-      it "disallows array elements that are not json objects" $
-        post "/articles?columns=id,body"
-          [json|[
-            {"id": 204, "body": "yyy"},
-            333,
-            "asdf",
-            {"id": 205, "body": "zzz"}]|] `shouldRespondWith`
-          [json|{
-              "code": "22023",
-              "details": null,
-              "hint": null,
-              "message": "argument of json_populate_recordset must be an array of objects"}|]
-          { matchStatus  = 400
-          , matchHeaders = []
-          }
-
-  describe "CSV insert" $ do
-    context "disparate csv types" $
-      it "succeeds with multipart response" $ do
-        pendingWith "Decide on what to do with CSV insert"
-        let inserted = [str|integer,double,varchar,boolean,date,money,enum
-            |13,3.14159,testing!,false,1900-01-01,$3.99,foo
-            |12,0.1,a string,true,1929-10-01,12,bar
-            |]
-        request methodPost "/menagerie" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] inserted
-           `shouldRespondWith` ResponseMatcher
-           { matchStatus  = 201
-           , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
-           , matchBody = bodyEquals inserted
-           }
-
-    context "requesting full representation" $ do
-      it "returns full details of inserted record" $
-        request methodPost "/no_pk"
-                     [("Content-Type", "text/csv"), ("Accept", "text/csv"),  ("Prefer", "return=representation")]
-                     "a,b\nbar,baz"
-          `shouldRespondWith` "a,b\nbar,baz"
-          { matchStatus  = 201
-          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
-          }
-
-      it "can post nulls" $
-        request methodPost "/no_pk"
-                     [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
-                     "a,b\nNULL,foo"
-          `shouldRespondWith` "a,b\n,foo"
-          { matchStatus  = 201
-          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
-          }
-
-      it "only returns the requested column header with its associated data" $
-        request methodPost "/projects?select=id"
-                     [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
-                     "id,name,client_id\n8,Xenix,1\n9,Windows NT,1"
-          `shouldRespondWith` "id\n8\n9"
-          { matchStatus  = 201
-          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
-                            "Content-Range" <:> "*/*"]
-          }
-
-    context "with wrong number of columns" $
-      it "fails for too few" $
-        request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
-        `shouldRespondWith`
-        [json|{"message":"All lines must have same number of fields"}|]
-        { matchStatus  = 400
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-    context "with unicode values" $
-      it "succeeds and returns usable location header" $ do
-        p <- request methodPost "/simple_pk2?select=extra,k"
-            [("Prefer", "tx=commit"), ("Prefer", "return=representation")]
-            [json| { "k":"圍棋", "extra":"￥" } |]
-        pure p `shouldRespondWith`
-          [json|[ { "k":"圍棋", "extra":"￥" } ]|]
-          { matchStatus = 201 }
-
-        let Just location = lookup hLocation $ simpleHeaders p
-        get location
-          `shouldRespondWith`
-            [json|[ { "k":"圍棋", "extra":"￥" } ]|]
-
-        request methodDelete location
-            [("Prefer", "tx=commit")]
-            ""
-          `shouldRespondWith`
-            204
-
-  describe "Row level permission" $
-    it "set user_id when inserting rows" $ do
-      request methodPost "/authors_only"
-          [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0", ("Prefer", "return=representation") ]
-          [json| { "secret": "nyancat" } |]
-        `shouldRespondWith`
-          [json|[{"owner":"jdoe","secret":"nyancat"}]|]
-          { matchStatus  = 201 }
-
-      request methodPost "/authors_only"
-          -- jwt token for jroe
-          [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.2e7mx0U4uDcInlbJVOBGlrRufwqWLINDIEDC1vS0nw8", ("Prefer", "return=representation") ]
-          [json| { "secret": "lolcat", "owner": "hacker" } |]
-        `shouldRespondWith`
-          [json|[{"owner":"jroe","secret":"lolcat"}]|]
-          { matchStatus  = 201 }
-
-  context "tables with self reference foreign keys" $ do
-    it "embeds parent after insert" $
-      request methodPost "/web_content?select=id,name,parent_content:p_web_id(name)"
-              [("Prefer", "return=representation")]
-        [json|{"id":6, "name":"wot", "p_web_id":4}|]
-        `shouldRespondWith`
-        [json|[{"id":6,"name":"wot","parent_content":{"name":"wut"}}]|]
-        { matchStatus  = 201
-        , matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]
-        }
-
-  context "table with limited privileges" $ do
-    it "succeeds inserting if correct select is applied" $
-      request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
-        [json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [json|[{"article_id":2,"user_id":1}]|]
-        { matchStatus  = 201
-        , matchHeaders = []
-        }
-
-    it "fails inserting if more columns are selected" $
-      request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
-        [json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
-      if actualPgVersion >= pgVersion112 then
-      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
-         else
-      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
-                                                                      )
-        { matchStatus  = 401
-        , matchHeaders = []
-        }
-
-    it "fails inserting if select is not specified" $
-      request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
-        [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
-      if actualPgVersion >= pgVersion112 then
-      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
-         else
-      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
-                                                                      )
-        { matchStatus  = 401
-        , matchHeaders = []
-        }
-
-    it "can insert in a table with no select and return=minimal" $ do
-      request methodPost "/insertonly"
-          [("Prefer", "return=minimal")]
-          [json| { "v":"some value" } |]
-        `shouldRespondWith`
-          ""
-          { matchStatus = 201 }
-
-  describe "Inserting into VIEWs" $ do
-    context "requesting no representation" $
-      it "succeeds with 201" $
-        post "/compound_pk_view"
-            [json|{"k1":1,"k2":"test","extra":2}|]
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 201
-            , matchHeaders = [ matchHeaderAbsent hLocation ]
-            }
-
-    context "requesting header only representation" $ do
-      it "returns a location header" $
-        request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")]
-            [json|{"k1":1,"k2":"test","extra":2}|]
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 201
-            , matchHeaders = [ "Location" <:> "/compound_pk_view?k1=eq.1&k2=eq.test"
-                             , "Content-Range" <:> "*/*" ]
-            }
-
-      it "should not throw and return location header when a PK is null" $
-        request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")]
-            [json|{"id":1}|]
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 201
-            , matchHeaders = [ "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"
-                             , "Content-Range" <:> "*/*" ]
-            }
diff --git a/test/Feature/JsonOperatorSpec.hs b/test/Feature/JsonOperatorSpec.hs
deleted file mode 100644
--- a/test/Feature/JsonOperatorSpec.hs
+++ /dev/null
@@ -1,253 +0,0 @@
-module Feature.JsonOperatorSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import PostgREST.Config.PgVersion (PgVersion, pgVersion112,
-                                   pgVersion121)
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: PgVersion -> SpecWith ((), Application)
-spec actualPgVersion = describe "json and jsonb operators" $ do
-  context "Shaping response with select parameter" $ do
-    it "obtains a json subfield one level with casting" $
-      get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
-        [json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "renames json subfield one level with casting" $
-      get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
-        [json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "fails on bad casting (data of the wrong format)" $
-      get "/complex_items?select=settings->foo->>bar::integer"
-        `shouldRespondWith` (
-        if actualPgVersion >= pgVersion121 then
-        [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]
-        else
-        [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
-                            )
-        { matchStatus  = 400 , matchHeaders = [] }
-
-    it "obtains a json subfield two levels (string)" $
-      get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
-        [json| [{"bar":"baz"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "renames json subfield two levels (string)" $
-      get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
-        [json| [{"myBar":"baz"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "obtains a json subfield two levels with casting (int)" $
-      get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
-        [json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "renames json subfield two levels with casting (int)" $
-      get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
-        [json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
-        { matchHeaders = [matchContentTypeJson] }
-
-    -- TODO the status code for the error is 404, this is because 42883 represents undefined function
-    -- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate
-    it "fails when a double arrow ->> is followed with a single arrow ->" $ do
-      get "/json_arr?select=data->>c->1"
-        `shouldRespondWith` (
-        if actualPgVersion >= pgVersion112 then
-        [json|
-          {"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
-           "details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
-           else
-        [json|
-          {"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
-           "details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
-                            )
-        { matchStatus  = 404 , matchHeaders = [] }
-      get "/json_arr?select=data->>c->b"
-        `shouldRespondWith` (
-        if actualPgVersion >= pgVersion112 then
-        [json|
-          {"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
-           "details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
-           else
-        [json|
-          {"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
-           "details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
-                            )
-        { matchStatus  = 404 , matchHeaders = [] }
-
-    context "with array index" $ do
-      it "can get array of ints and alias/cast it" $ do
-        get "/json_arr?select=data->>0::int&id=in.(1,2)" `shouldRespondWith`
-          [json| [{"data":1}, {"data":4}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/json_arr?select=idx0:data->>0::int,idx1:data->>1::int&id=in.(1,2)" `shouldRespondWith`
-          [json| [{"idx0":1,"idx1":2}, {"idx0":4,"idx1":5}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "can get nested array of ints" $ do
-        get "/json_arr?select=data->0->>1::int&id=in.(3,4)" `shouldRespondWith`
-          [json| [{"data":8}, {"data":7}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/json_arr?select=data->0->0->>1::int&id=in.(3,4)" `shouldRespondWith`
-          [json| [{"data":null}, {"data":6}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "can get array of objects" $ do
-        get "/json_arr?select=data->0->>a&id=in.(5,6)" `shouldRespondWith`
-          [json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/json_arr?select=data->0->a->>2&id=in.(5,6)" `shouldRespondWith`
-          [json| [{"a":null}, {"a":"3"}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "can get array in object keys" $ do
-        get "/json_arr?select=data->c->>0::json&id=in.(7,8)" `shouldRespondWith`
-          [json| [{"c":1}, {"c":{"d": [4,5,6,7,8]}}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/json_arr?select=data->c->0->d->>4::int&id=in.(7,8)" `shouldRespondWith`
-          [json| [{"d":null}, {"d":8}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "only treats well formed numbers as indexes" $
-        get "/json_arr?select=data->0->0xy1->1->23-xy-45->1->xy-6->>0::int&id=eq.9" `shouldRespondWith`
-          [json| [{"xy-6":3}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-    context "finishing json path with single arrow ->" $ do
-      it "works when finishing with a key" $ do
-        get "/json_arr?select=data->c&id=in.(7,8)" `shouldRespondWith`
-          [json| [{"c":[1,2,3]}, {"c":[{"d": [4,5,6,7,8]}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
-          [json| [{"a":"A"}, {"a":[1,2,3]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "works when finishing with an index" $ do
-        get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
-          [json| [{"a":"A"}, {"a":[1,2,3]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/json_arr?select=data->c->0->d&id=eq.8" `shouldRespondWith`
-          [json| [{"d":[4,5,6,7,8]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-  context "filtering response" $ do
-    it "can filter by properties inside json column" $ do
-      get "/json_table?data->foo->>bar=eq.baz" `shouldRespondWith`
-        [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/json_table?data->foo->>bar=eq.fake" `shouldRespondWith`
-        [json| [] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "can filter by properties inside json column using not" $
-      get "/json_table?data->foo->>bar=not.eq.baz" `shouldRespondWith`
-        [json| [] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "can filter by properties inside json column using ->>" $
-      get "/json_table?data->>id=eq.1" `shouldRespondWith`
-        [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "can be filtered with and/or" $
-      get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
-        [json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
-
-    it "can filter by array indexes" $ do
-      get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`
-        [json| [{"data":[1, 2, 3]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data&data->1->>2=eq.13" `shouldRespondWith`
-        [json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data&data->1->>b=eq.B" `shouldRespondWith`
-        [json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data&data->1->b->>1=eq.5" `shouldRespondWith`
-        [json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "can filter jsonb" $ do
-      get "/jsonb_test?data=eq.{\"e\":1}" `shouldRespondWith`
-        [json| [{"id":4,"data":{"e": 1}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/jsonb_test?data->a=eq.{\"b\":2}" `shouldRespondWith`
-        [json| [{"id":1,"data":{"a": {"b": 2}}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/jsonb_test?data->c=eq.[1,2,3]" `shouldRespondWith`
-        [json| [{"id":2,"data":{"c": [1, 2, 3]}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/jsonb_test?data->0=eq.{\"d\":\"test\"}" `shouldRespondWith`
-        [json| [{"id":3,"data":[{"d": "test"}]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-  context "ordering response" $ do
-    it "orders by a json column property asc" $
-      get "/json_table?order=data->>id.asc" `shouldRespondWith`
-        [json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "orders by a json column with two level property nulls first" $
-      get "/json_table?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
-        [json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-  context "Patching record, in a nonempty table" $
-    it "can set a json column to escaped value" $ do
-      request methodPatch "/json_table?data->>id=eq.3"
-          [("Prefer", "return=representation")]
-          [json| { "data": { "id":" \"escaped" } } |]
-        `shouldRespondWith`
-          [json| [{ "data": { "id":" \"escaped" } }] |]
-
-  context "json array negative index" $ do
-    it "can select with negative indexes" $ do
-      get "/json_arr?select=data->>-1::int&id=in.(1,2)" `shouldRespondWith`
-        [json| [{"data":3}, {"data":6}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data->0->>-2::int&id=in.(3,4)" `shouldRespondWith`
-        [json| [{"data":8}, {"data":7}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data->-2->>a&id=in.(5,6)" `shouldRespondWith`
-        [json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "can filter with negative indexes" $ do
-      get "/json_arr?select=data&data->>-3=eq.1" `shouldRespondWith`
-        [json| [{"data":[1, 2, 3]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data&data->-1->>-3=eq.11" `shouldRespondWith`
-        [json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data&data->-1->>b=eq.B" `shouldRespondWith`
-        [json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data&data->-1->b->>-1=eq.5" `shouldRespondWith`
-        [json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "should fail on badly formed negatives" $ do
-      get "/json_arr?select=data->>-78xy" `shouldRespondWith`
-        [json|
-          {"details": "unexpected 'x' expecting digit, \"->\", \"::\" or end of input",
-           "message": "\"failed to parse select parameter (data->>-78xy)\" (line 1, column 11)"} |]
-        { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data->>--34" `shouldRespondWith`
-        [json|
-          {"details": "unexpected \"-\" expecting digit",
-           "message": "\"failed to parse select parameter (data->>--34)\" (line 1, column 9)"} |]
-        { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
-      get "/json_arr?select=data->>-xy-4" `shouldRespondWith`
-        [json|
-          {"details":"unexpected \"x\" expecting digit",
-           "message":"\"failed to parse select parameter (data->>-xy-4)\" (line 1, column 9)"} |]
-        { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
diff --git a/test/Feature/LegacyGucsSpec.hs b/test/Feature/LegacyGucsSpec.hs
deleted file mode 100644
--- a/test/Feature/LegacyGucsSpec.hs
+++ /dev/null
@@ -1,68 +0,0 @@
-module Feature.LegacyGucsSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec          hiding (pendingWith)
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "remote procedure call with legacy gucs disabled" $ do
-    it "custom header is set" $
-      request methodPost "/rpc/get_guc_value" [("Custom-Header", "test")]
-        [json| { "prefix": "request.headers", "name": "custom-header" } |]
-          `shouldRespondWith`
-          [json|"test"|]
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson ]
-          }
-
-    it "standard header is set" $
-      request methodPost "/rpc/get_guc_value" [("Origin", "http://example.com")]
-        [json| { "prefix": "request.headers", "name": "origin" } |]
-          `shouldRespondWith`
-          [json|"http://example.com"|]
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson ]
-          }
-
-    it "current role is available as GUC claim" $
-      request methodPost "/rpc/get_guc_value" []
-        [json| { "prefix": "request.jwt.claims", "name": "role" } |]
-          `shouldRespondWith`
-          [json|"postgrest_test_anonymous"|]
-          { matchStatus  = 200
-          , matchHeaders = [ matchContentTypeJson ]
-          }
-
-    it "single cookie ends up as claims" $
-      request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
-        [json| {"prefix": "request.cookies", "name":"acookie"} |]
-          `shouldRespondWith`
-          [json|"cookievalue"|]
-          { matchStatus = 200
-          , matchHeaders = []
-          }
-
-    it "multiple cookies ends up as claims" $
-      request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
-        [json| {"prefix": "request.cookies", "name":"secondcookie"} |]
-          `shouldRespondWith`
-          [json|"anothervalue"|]
-          { matchStatus = 200
-          , matchHeaders = []
-          }
-
-    it "gets the Authorization value" $
-      request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
-        [json| {"prefix": "request.headers", "name":"authorization"} |]
-          `shouldRespondWith`
-          [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
-          { matchStatus = 200
-          , matchHeaders = []
-          }
diff --git a/test/Feature/MultipleSchemaSpec.hs b/test/Feature/MultipleSchemaSpec.hs
deleted file mode 100644
--- a/test/Feature/MultipleSchemaSpec.hs
+++ /dev/null
@@ -1,331 +0,0 @@
-module Feature.MultipleSchemaSpec where
-
-import Control.Lens    ((^?))
-import Data.Aeson.Lens
-import Data.Aeson.QQ
-
-import Network.HTTP.Types
-import Network.Wai        (Application)
-import Network.Wai.Test   (SResponse (simpleHeaders), simpleBody)
-
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "multiple schemas in single instance" $ do
-    context "Reading tables on different schemas" $ do
-      it "succeeds in reading table from default schema v1 if no schema is selected via header" $
-        request methodGet "/parents" [] "" `shouldRespondWith`
-          [json|[
-            {"id":1,"name":"parent v1-1"},
-            {"id":2,"name":"parent v1-2"}
-          ]|]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
-          }
-
-      it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $
-        request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`
-          [json|[
-            {"id":1,"name":"parent v1-1"},
-            {"id":2,"name":"parent v1-2"}
-          ]|]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
-          }
-
-      it "succeeds in reading table from schema v2" $
-        request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`
-          [json|[
-            {"id":3,"name":"parent v2-3"},
-            {"id":4,"name":"parent v2-4"}
-          ]|]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
-          }
-
-      it "succeeds in reading another_table from schema v2" $
-        request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`
-          [json|[
-            {"id":5,"another_value":"value 5"},
-            {"id":6,"another_value":"value 6"}
-          ]|]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
-          }
-
-      it "doesn't find another_table in schema v1" $
-        request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404
-
-      it "fails trying to read table from unkown schema" $
-        request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
-          [json|{"message":"The schema must be one of the following: v1, v2"}|]
-          {
-            matchStatus = 406
-          }
-
-    context "Inserting tables on different schemas" $ do
-      it "succeeds inserting on default schema and returning it" $
-        request methodPost "/children"
-            [("Prefer", "return=representation")]
-            [json|{"id": 0, "name": "child v1-1", "parent_id": 1}|]
-          `shouldRespondWith`
-            [json|[{"id": 0, "name": "child v1-1", "parent_id": 1}]|]
-            {
-              matchStatus = 201
-            , matchHeaders = ["Content-Profile" <:> "v1"]
-            }
-
-      it "succeeds inserting on the v1 schema and returning its parent" $
-        request methodPost "/children?select=id,parent(*)"
-            [("Prefer", "return=representation"), ("Content-Profile", "v1")]
-            [json|{"id": 0, "name": "child v1-2", "parent_id": 2}|]
-          `shouldRespondWith`
-            [json|[{"id": 0, "parent": {"id": 2, "name": "parent v1-2"}}]|]
-            {
-              matchStatus = 201
-            , matchHeaders = ["Content-Profile" <:> "v1"]
-            }
-
-      it "succeeds inserting on the v2 schema and returning its parent" $
-        request methodPost "/children?select=id,parent(*)"
-            [("Prefer", "return=representation"), ("Content-Profile", "v2")]
-            [json|{"id": 0, "name": "child v2-3", "parent_id": 3}|]
-          `shouldRespondWith`
-            [json|[{"id": 0, "parent": {"id": 3, "name": "parent v2-3"}}]|]
-            {
-              matchStatus = 201
-            , matchHeaders = ["Content-Profile" <:> "v2"]
-            }
-
-      it "fails when inserting on an unknown schema" $
-        request methodPost "/children" [("Content-Profile", "unknown")]
-          [json|{"name": "child 4", "parent_id": 4}|]
-          `shouldRespondWith`
-          [json|{"message":"The schema must be one of the following: v1, v2"}|]
-          {
-            matchStatus = 406
-          }
-
-    context "calling procs on different schemas" $ do
-      it "succeeds in calling the default schema proc" $
-        request methodGet "/rpc/get_parents_below?id=6" [] ""
-          `shouldRespondWith`
-          [json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
-          }
-
-      it "succeeds in calling the v1 schema proc and embedding" $
-        request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v1")] ""
-          `shouldRespondWith`
-          [json| [
-            {"id":1,"name":"parent v1-1","children":[{"id":1,"name":"child v1-1"}]},
-            {"id":2,"name":"parent v1-2","children":[{"id":2,"name":"child v1-2"}]}] |]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
-          }
-
-      it "succeeds in calling the v2 schema proc and embedding" $
-        request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v2")] ""
-          `shouldRespondWith`
-          [json| [
-            {"id":3,"name":"parent v2-3","children":[{"id":1,"name":"child v2-3"}]},
-            {"id":4,"name":"parent v2-4","children":[]}] |]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
-          }
-
-      it "succeeds in calling the v2 schema proc with POST by using Content-Profile" $
-        request methodPost "/rpc/get_parents_below?select=id,name" [("Content-Profile", "v2")]
-          [json|{"id": "6"}|]
-          `shouldRespondWith`
-          [json| [
-            {"id":3,"name":"parent v2-3"},
-            {"id":4,"name":"parent v2-4"}]|]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
-          }
-
-    context "Modifying tables on different schemas" $ do
-      it "succeeds in patching on the v1 schema and returning its parent" $
-        request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]
-          [json|{"name": "child v1-1 updated"}|]
-          `shouldRespondWith`
-          [json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
-          }
-
-      it "succeeds in patching on the v2 schema and returning its parent" $
-        request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
-          [json|{"name": "child v2-1 updated"}|]
-          `shouldRespondWith`
-          [json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
-          }
-
-      it "succeeds on deleting on the v2 schema" $ do
-        request methodDelete "/children?id=eq.1"
-            [("Content-Profile", "v2"), ("Prefer", "return=representation")]
-            ""
-          `shouldRespondWith`
-            [json|[{"id": 1, "name": "child v2-3", "parent_id": 3}]|]
-            { matchHeaders = ["Content-Profile" <:> "v2"] }
-
-      it "succeeds on PUT on the v2 schema" $
-        request methodPut "/children?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
-          [json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]
-          `shouldRespondWith`
-          [json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]
-          {
-            matchStatus = 200
-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
-          }
-
-    context "OpenAPI output" $ do
-      it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do
-          req <- request methodGet "/" [] ""
-
-          liftIO $ do
-            simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"
-
-            let def = simpleBody req ^? key "definitions" . key "parents"
-
-            def `shouldBe` Just
-                [aesonQQ|
-                  {
-                    "type" : "object",
-                    "properties" : {
-                      "id" : {
-                        "description" : "Note:\nThis is a Primary Key.<pk/>",
-                        "format" : "integer",
-                        "type" : "integer"
-                      },
-                      "name" : {
-                        "format" : "text",
-                        "type" : "string"
-                      }
-                    },
-                    "required" : [
-                      "id"
-                    ]
-                  }
-                |]
-
-      it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do
-          r <- request methodGet "/" [("Accept-Profile", "v1")] ""
-
-          liftIO $ do
-            simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"
-
-            let def = simpleBody r ^? key "definitions" . key "parents"
-
-            def `shouldBe` Just
-                [aesonQQ|
-                  {
-                    "type" : "object",
-                    "properties" : {
-                      "id" : {
-                        "description" : "Note:\nThis is a Primary Key.<pk/>",
-                        "format" : "integer",
-                        "type" : "integer"
-                      },
-                      "name" : {
-                        "format" : "text",
-                        "type" : "string"
-                      }
-                    },
-                    "required" : [
-                      "id"
-                    ]
-                  }
-                |]
-
-      it "succeeds in reading table definition from schema v2" $ do
-          r <- request methodGet "/" [("Accept-Profile", "v2")] ""
-
-          liftIO $ do
-            simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
-
-            let def = simpleBody r ^? key "definitions" . key "parents"
-
-            def `shouldBe` Just
-                [aesonQQ|
-                  {
-                    "type" : "object",
-                    "properties" : {
-                      "id" : {
-                        "description" : "Note:\nThis is a Primary Key.<pk/>",
-                        "format" : "integer",
-                        "type" : "integer"
-                      },
-                      "name" : {
-                        "format" : "text",
-                        "type" : "string"
-                      }
-                    },
-                    "required" : [
-                      "id"
-                    ]
-                  }
-                |]
-
-      it "succeeds in reading another_table definition from schema v2" $ do
-          r <- request methodGet "/" [("Accept-Profile", "v2")] ""
-
-          liftIO $ do
-            simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
-
-            let def = simpleBody r ^? key "definitions" . key "another_table"
-
-            def `shouldBe` Just
-                [aesonQQ|
-                  {
-                    "type" : "object",
-                    "properties" : {
-                      "id" : {
-                        "description" : "Note:\nThis is a Primary Key.<pk/>",
-                        "format" : "integer",
-                        "type" : "integer"
-                      },
-                      "another_value" : {
-                        "format" : "text",
-                        "type" : "string"
-                      }
-                    },
-                    "required" : [
-                      "id"
-                    ]
-                  }
-                |]
-
-      it "doesn't find another_table definition in schema v1" $ do
-        r <- request methodGet "/" [("Accept-Profile", "v1")] ""
-
-        liftIO $ do
-          let def = simpleBody r ^? key "definitions" . key "another_table"
-          def `shouldBe` Nothing
-
-      it "fails trying to read definitions from unkown schema" $
-        request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
-          [json|{"message":"The schema must be one of the following: v1, v2"}|]
-          {
-            matchStatus = 406
-          }
diff --git a/test/Feature/NoJwtSpec.hs b/test/Feature/NoJwtSpec.hs
deleted file mode 100644
--- a/test/Feature/NoJwtSpec.hs
+++ /dev/null
@@ -1,31 +0,0 @@
-module Feature.NoJwtSpec where
-
--- {{{ Imports
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude
-import SpecHelper
--- }}}
-
-spec :: SpecWith ((), Application)
-spec = describe "server started without JWT secret" $ do
-
-  -- this test will stop working 9999999999s after the UNIX EPOCH
-  it "responds with error on attempted auth" $ do
-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
-    request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith`
-      [json|{"message":"Server lacks JWT secret"}|]
-      { matchStatus  = 500
-      , matchHeaders = [ matchContentTypeJson ]
-      }
-
-  it "behaves normally when user does not attempt auth" $
-    request methodGet "/items" [] ""
-      `shouldRespondWith` 200
diff --git a/test/Feature/NonexistentSchemaSpec.hs b/test/Feature/NonexistentSchemaSpec.hs
deleted file mode 100644
--- a/test/Feature/NonexistentSchemaSpec.hs
+++ /dev/null
@@ -1,17 +0,0 @@
-module Feature.NonexistentSchemaSpec where
-
-import Network.Wai (Application)
-
-import Test.Hspec
-import Test.Hspec.Wai
-
-import Protolude hiding (get)
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "Non existent api schema" $ do
-    it "succeeds when requesting root path" $
-      get "/" `shouldRespondWith` 200
-
-    it "gives 404 when requesting a nonexistent table in this nonexistent schema" $
-      get "/nonexistent_table" `shouldRespondWith` 404
diff --git a/test/Feature/OpenApiSpec.hs b/test/Feature/OpenApiSpec.hs
deleted file mode 100644
--- a/test/Feature/OpenApiSpec.hs
+++ /dev/null
@@ -1,589 +0,0 @@
-module Feature.OpenApiSpec where
-
-import Control.Lens     ((^?))
-import Data.Aeson.Types (Value (..))
-import Network.Wai      (Application)
-import Network.Wai.Test (SResponse (..))
-
-import Data.Aeson.Lens
-import Data.Aeson.QQ
-import Network.HTTP.Types
-import Test.Hspec         hiding (pendingWith)
-import Test.Hspec.Wai
-
-import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
-                                   pgVersion110)
-
-import PostgREST.Version (docsVersion)
-import Protolude         hiding (get)
-import SpecHelper
-
-spec :: PgVersion -> SpecWith ((), Application)
-spec actualPgVersion = describe "OpenAPI" $ do
-  it "root path returns a valid openapi spec" $ do
-    validateOpenApiResponse [("Accept", "application/openapi+json")]
-    request methodHead "/" (acceptHdrs "application/openapi+json") ""
-      `shouldRespondWith` "" { matchStatus  = 200 }
-
-  it "should respond to openapi request on none root path with 415" $
-    request methodGet "/items"
-            (acceptHdrs "application/openapi+json") ""
-      `shouldRespondWith` 415
-
-  it "includes postgrest.org current version api docs" $ do
-    r <- simpleBody <$> get "/"
-
-    let docsUrl = r ^? key "externalDocs" . key "url"
-
-    liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
-
-  describe "table" $ do
-
-    it "includes paths to tables" $ do
-      r <- simpleBody <$> get "/"
-
-      let method s = key "paths" . key "/child_entities" . key s
-          childGetSummary = r ^? method "get" . key "summary"
-          childGetDescription = r ^? method "get" . key "description"
-          getParameters = r ^? method "get" . key "parameters"
-          postParameters = r ^? method "post" . key "parameters"
-          postResponse = r ^? method "post" . key "responses" . key "201" . key "description"
-          patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"
-          deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"
-
-      let grandChildGet s = key "paths" . key "/grandchild_entities" . key "get" . key s
-          grandChildGetSummary = r ^? grandChildGet "summary"
-          grandChildGetDescription = r ^? grandChildGet "description"
-
-      liftIO $ do
-
-        childGetSummary `shouldBe` Just "child_entities comment"
-
-        childGetDescription `shouldBe` Nothing
-
-        grandChildGetSummary `shouldBe` Just "grandchild_entities summary"
-
-        grandChildGetDescription `shouldBe` Just "grandchild_entities description\nthat spans\nmultiple lines"
-
-        getParameters `shouldBe` Just
-          [aesonQQ|
-            [
-              { "$ref": "#/parameters/rowFilter.child_entities.id" },
-              { "$ref": "#/parameters/rowFilter.child_entities.name" },
-              { "$ref": "#/parameters/rowFilter.child_entities.parent_id" },
-              { "$ref": "#/parameters/select" },
-              { "$ref": "#/parameters/order" },
-              { "$ref": "#/parameters/range" },
-              { "$ref": "#/parameters/rangeUnit" },
-              { "$ref": "#/parameters/offset" },
-              { "$ref": "#/parameters/limit" },
-              { "$ref": "#/parameters/preferCount" }
-            ]
-          |]
-
-        postParameters `shouldBe` Just
-          [aesonQQ|
-            [
-              { "$ref": "#/parameters/body.child_entities" },
-              { "$ref": "#/parameters/select" },
-              { "$ref": "#/parameters/preferReturn" }
-            ]
-          |]
-
-        postResponse `shouldBe` Just "Created"
-
-        patchResponse `shouldBe` Just "No Content"
-
-        deleteResponse `shouldBe` Just "No Content"
-
-    it "includes an array type for GET responses" $ do
-      r <- simpleBody <$> get "/"
-
-      let childGetSchema = r ^? key "paths"
-            . key "/child_entities"
-            . key "get"
-            . key "responses"
-            . key "200"
-            . key "schema"
-
-      liftIO $
-        childGetSchema `shouldBe` Just
-          [aesonQQ|
-            {
-              "items": {
-                "$ref": "#/definitions/child_entities"
-              },
-              "type": "array"
-            }
-          |]
-
-    it "includes definitions to tables" $ do
-      r <- simpleBody <$> get "/"
-
-      let def = r ^? key "definitions" . key "child_entities"
-
-      liftIO $
-
-        def `shouldBe` Just
-          [aesonQQ|
-            {
-              "type": "object",
-              "description": "child_entities comment",
-              "properties": {
-                "id": {
-                  "description": "child_entities id comment\n\nNote:\nThis is a Primary Key.<pk/>",
-                  "format": "integer",
-                  "type": "integer"
-                },
-                "name": {
-                  "description": "child_entities name comment. Can be longer than sixty-three characters long",
-                  "format": "text",
-                  "type": "string"
-                },
-                "parent_id": {
-                  "description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' column='id'/>",
-                  "format": "integer",
-                  "type": "integer"
-                }
-              },
-              "required": [
-                "id"
-              ]
-            }
-          |]
-
-    it "doesn't include privileged table for anonymous" $ do
-      r <- simpleBody <$> get "/"
-      let tablePath = r ^? key "paths" . key "/authors_only"
-
-      liftIO $ tablePath `shouldBe` Nothing
-
-    it "includes table if user has permission" $ do
-      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
-      r <- simpleBody <$> request methodGet "/" [auth] ""
-      let tableTag = r ^? key "paths" . key "/authors_only"
-                    . key "post"  . key "tags"
-                    . nth 0
-      liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]
-
-  describe "Foreign table" $
-
-    it "includes foreign table properties" $ do
-      r <- simpleBody <$> get "/"
-
-      let method s = key "paths" . key "/projects_dump" . key s
-          getSummary = r ^? method "get" . key "summary"
-          getDescription = r ^? method "get" . key "description"
-          getParameters = r ^? method "get" . key "parameters"
-
-      liftIO $ do
-
-        getSummary `shouldBe` Just "A temporary projects dump"
-
-        getDescription `shouldBe` Just "Just a test for foreign tables"
-
-        getParameters `shouldBe` Just
-          [aesonQQ|
-            [
-              { "$ref": "#/parameters/rowFilter.projects_dump.id" },
-              { "$ref": "#/parameters/rowFilter.projects_dump.name" },
-              { "$ref": "#/parameters/rowFilter.projects_dump.client_id" },
-              { "$ref": "#/parameters/select" },
-              { "$ref": "#/parameters/order" },
-              { "$ref": "#/parameters/range" },
-              { "$ref": "#/parameters/rangeUnit" },
-              { "$ref": "#/parameters/offset" },
-              { "$ref": "#/parameters/limit" },
-              { "$ref": "#/parameters/preferCount" }
-            ]
-          |]
-
-  when (actualPgVersion >= pgVersion100) $ do
-    describe "Partitioned table" $
-
-      it "includes partitioned table properties" $ do
-        r <- simpleBody <$> get "/"
-
-        let method s = key "paths" . key "/car_models" . key s
-            getSummary = r ^? method "get" . key "summary"
-            getDescription = r ^? method "get" . key "description"
-            getParameterName = r ^? method "get" . key "parameters" . nth 0 . key "$ref"
-            getParameterYear = r ^? method "get" . key "parameters" . nth 1 . key "$ref"
-            getParameterRef = r ^? method "get" . key "parameters" . nth 2 . key "$ref"
-
-        liftIO $ do
-
-          getSummary `shouldBe` Just "A partitioned table"
-
-          getDescription `shouldBe` Just "A test for partitioned tables"
-
-          getParameterName `shouldBe` Just "#/parameters/rowFilter.car_models.name"
-
-          getParameterYear `shouldBe` Just "#/parameters/rowFilter.car_models.year"
-
-          when (actualPgVersion >= pgVersion110) $
-            getParameterRef `shouldBe` Just "#/parameters/rowFilter.car_models.car_brand_name"
-
-  describe "Materialized view" $
-
-    it "includes materialized view properties" $ do
-      r <- simpleBody <$> get "/"
-
-      let method s = key "paths" . key "/materialized_projects" . key s
-          summary = r ^? method "get" . key "summary"
-          description = r ^? method "get" . key "description"
-          parameters = r ^? method "get" . key "parameters"
-
-      liftIO $ do
-
-        summary `shouldBe` Just "A materialized view for projects"
-
-        description `shouldBe` Just "Just a test for materialized views"
-
-        parameters `shouldBe` Just
-          [aesonQQ|
-            [
-              { "$ref": "#/parameters/rowFilter.materialized_projects.id" },
-              { "$ref": "#/parameters/rowFilter.materialized_projects.name" },
-              { "$ref": "#/parameters/rowFilter.materialized_projects.client_id" },
-              { "$ref": "#/parameters/select" },
-              { "$ref": "#/parameters/order" },
-              { "$ref": "#/parameters/range" },
-              { "$ref": "#/parameters/rangeUnit" },
-              { "$ref": "#/parameters/offset" },
-              { "$ref": "#/parameters/limit" },
-              { "$ref": "#/parameters/preferCount" }
-            ]
-          |]
-
-  describe "VIEW that has a source FK based on a UNIQUE key" $
-
-    it "includes fk description" $ do
-      r <- simpleBody <$> get "/"
-
-      let referralLink = r ^? key "definitions" . key "referrals" . key "properties" . key "link"
-
-      liftIO $
-        referralLink `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "integer",
-              "type": "integer",
-              "description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' column='link'/>"
-            }
-          |]
-
-  describe "PostgreSQL to Swagger Type Mapping" $ do
-
-    it "character varying to string" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character_varying"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "character varying",
-              "type": "string"
-            }
-          |]
-    it "character(1) to string" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "maxLength": 1,
-              "format": "character",
-              "type": "string"
-            }
-          |]
-
-    it "text to string" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "text",
-              "type": "string"
-            }
-          |]
-
-    it "boolean to boolean" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_boolean"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "boolean",
-              "type": "boolean"
-            }
-          |]
-
-    it "smallint to integer" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_smallint"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "smallint",
-              "type": "integer"
-            }
-          |]
-
-    it "integer to integer" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_integer"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "integer",
-              "type": "integer"
-            }
-          |]
-
-    it "bigint to integer" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "bigint",
-              "type": "integer"
-            }
-          |]
-
-    it "numeric to number" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "numeric",
-              "type": "number"
-            }
-          |]
-
-    it "real to number" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_real"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "real",
-              "type": "number"
-            }
-          |]
-
-    it "double_precision to number" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_double_precision"
-
-      liftIO $
-
-        types `shouldBe` Just
-          [aesonQQ|
-            {
-              "format": "double precision",
-              "type": "number"
-            }
-          |]
-
-  describe "Detects default values" $ do
-
-    it "text" $ do
-      r <- simpleBody <$> get "/"
-
-      let defaultValue = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "text" . key "default"
-
-      liftIO $
-
-        defaultValue `shouldBe` Just "default"
-
-    it "boolean" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "boolean" . key "default"
-
-      liftIO $
-
-        types `shouldBe` Just (Bool False)
-
-    it "integer" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "integer" . key "default"
-
-      liftIO $
-
-        types `shouldBe` Just (Number 42)
-
-    it "numeric" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "numeric" . key "default"
-
-      liftIO $
-
-        types `shouldBe` Just (Number 42.2)
-
-    it "date" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "date" . key "default"
-
-      liftIO $
-
-        types `shouldBe` Just "1900-01-01"
-
-    it "time" $ do
-      r <- simpleBody <$> get "/"
-
-      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "time" . key "default"
-
-      liftIO $
-
-        types `shouldBe` Just "13:00:00"
-
-  describe "RPC" $ do
-
-    it "includes function summary/description and body schema for arguments" $ do
-      r <- simpleBody <$> get "/"
-
-      let method s = key "paths" . key "/rpc/varied_arguments" . key s
-          args = r ^? method "post" . key "parameters" . nth 0 . key "schema"
-          summary = r ^? method "post" . key "summary"
-          description = r ^? method "post" . key "description"
-
-      liftIO $ do
-
-        summary `shouldBe` Just "An RPC function"
-
-        description `shouldBe` Just "Just a test for RPC function arguments"
-
-        args `shouldBe` Just
-          [aesonQQ|
-            {
-              "required": [
-                "double",
-                "varchar",
-                "boolean",
-                "date",
-                "money",
-                "enum",
-                "arr"
-              ],
-              "properties": {
-                "double": {
-                  "format": "double precision",
-                  "type": "number"
-                },
-                "varchar": {
-                  "format": "character varying",
-                  "type": "string"
-                },
-                "boolean": {
-                  "format": "boolean",
-                  "type": "boolean"
-                },
-                "date": {
-                  "format": "date",
-                  "type": "string"
-                },
-                "money": {
-                  "format": "money",
-                  "type": "string"
-                },
-                "enum": {
-                  "format": "enum_menagerie_type",
-                  "type": "string"
-                },
-                "arr": {
-                  "format": "text[]",
-                  "type": "string"
-                },
-                "integer": {
-                  "format": "integer",
-                  "type": "integer"
-                },
-                "json": {
-                  "format": "json",
-                  "type": "string"
-                },
-                "jsonb": {
-                  "format": "jsonb",
-                  "type": "string"
-                }
-              },
-              "type": "object",
-              "description": "An RPC function\n\nJust a test for RPC function arguments"
-            }
-          |]
-
-    it "doesn't include privileged function for anonymous" $ do
-      r <- simpleBody <$> get "/"
-      let funcPath = r ^? key "paths" . key "/rpc/privileged_hello"
-
-      liftIO $ funcPath `shouldBe` Nothing
-
-    it "includes function if user has permission" $ do
-      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
-      r <- simpleBody <$> request methodGet "/" [auth] ""
-      let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"
-                    . key "post"  . key "tags"
-                    . nth 0
-
-      liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]
-
-    it "doesn't include OUT params of function as required parameters" $ do
-      r <- simpleBody <$> get "/"
-      let params = r ^? key "paths" . key "/rpc/many_out_params"
-                      . key "post" . key "parameters" .  nth 0
-                      . key "schema". key "required"
-
-      liftIO $ params `shouldBe` Nothing
-
-    it "includes INOUT params(with no DEFAULT) of function as required parameters" $ do
-      r <- simpleBody <$> get "/"
-      let params = r ^? key "paths" . key "/rpc/many_inout_params"
-                      . key "post" . key "parameters" .  nth 0
-                      . key "schema". key "required"
-
-      liftIO $ params `shouldBe` Just [aesonQQ|["num", "str"]|]
-
diff --git a/test/Feature/OptionsSpec.hs b/test/Feature/OptionsSpec.hs
deleted file mode 100644
--- a/test/Feature/OptionsSpec.hs
+++ /dev/null
@@ -1,87 +0,0 @@
-module Feature.OptionsSpec where
-
-import Network.Wai      (Application)
-import Network.Wai.Test (SResponse (..))
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-
-import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
-                                   pgVersion110)
-
-import Protolude
-import SpecHelper
-
-spec :: PgVersion -> SpecWith ((), Application)
-spec actualPgVersion = describe "Allow header" $ do
-  context "a table" $ do
-    it "includes read/write verbs for writeable table" $ do
-      r <- request methodOptions "/items" [] ""
-      liftIO $
-        simpleHeaders r `shouldSatisfy`
-          matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
-
-  when (actualPgVersion >= pgVersion100) $
-    context "a partitioned table" $ do
-      it "includes read/write verbs for writeable partitioned tables" $ do
-        r <- request methodOptions "/car_models" [] ""
-        liftIO $
-          simpleHeaders r `shouldSatisfy`
-            matchHeader "Allow" (
-              if actualPgVersion >= pgVersion110 then
-                "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
-              else
-                "OPTIONS,GET,HEAD,POST,PATCH,DELETE"
-            )
-
-  context "a view" $ do
-    context "auto updatable" $ do
-      it "includes read/write verbs for auto updatable views with pk" $ do
-        r <- request methodOptions "/projects_auto_updatable_view_with_pk" [] ""
-        liftIO $
-          simpleHeaders r `shouldSatisfy`
-            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
-
-      it "includes read/write verbs for auto updatable views without pk" $ do
-        r <- request methodOptions "/projects_auto_updatable_view_without_pk" [] ""
-        liftIO $
-          simpleHeaders r `shouldSatisfy`
-            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PATCH,DELETE"
-
-    context "non auto updatable" $ do
-      it "includes read verbs for non auto updatable views" $ do
-        r <- request methodOptions "/projects_view_without_triggers" [] ""
-        liftIO $
-          simpleHeaders r `shouldSatisfy`
-            matchHeader "Allow" "OPTIONS,GET,HEAD"
-
-      it "includes read/write verbs for insertable, updatable and deletable views with pk" $ do
-        r <- request methodOptions "/projects_view_with_all_triggers_with_pk" [] ""
-        liftIO $
-          simpleHeaders r `shouldSatisfy`
-            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
-
-      it "includes read/write verbs for insertable, updatable and deletable views without pk" $ do
-        r <- request methodOptions "/projects_view_with_all_triggers_without_pk" [] ""
-        liftIO $
-          simpleHeaders r `shouldSatisfy`
-            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PATCH,DELETE"
-
-      it "includes read and insert verbs for insertable views" $ do
-        r <- request methodOptions "/projects_view_with_insert_trigger" [] ""
-        liftIO $
-          simpleHeaders r `shouldSatisfy`
-            matchHeader "Allow" "OPTIONS,GET,HEAD,POST"
-
-      it "includes read and update verbs for updatable views" $ do
-        r <- request methodOptions "/projects_view_with_update_trigger" [] ""
-        liftIO $
-          simpleHeaders r `shouldSatisfy`
-            matchHeader "Allow" "OPTIONS,GET,HEAD,PATCH"
-
-      it "includes read and delete verbs for deletable views" $ do
-        r <- request methodOptions "/projects_view_with_delete_trigger" [] ""
-        liftIO $
-          simpleHeaders r `shouldSatisfy`
-            matchHeader "Allow" "OPTIONS,GET,HEAD,DELETE"
diff --git a/test/Feature/ProxySpec.hs b/test/Feature/ProxySpec.hs
deleted file mode 100644
--- a/test/Feature/ProxySpec.hs
+++ /dev/null
@@ -1,13 +0,0 @@
-module Feature.ProxySpec where
-
-import Network.Wai (Application)
-import Test.Hspec  hiding (pendingWith)
-
-import Protolude
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "GET / with proxy" $
-    it "returns a valid openapi spec with proxy" $
-      validateOpenApiResponse [("Accept", "application/openapi+json")]
diff --git a/test/Feature/QueryLimitedSpec.hs b/test/Feature/QueryLimitedSpec.hs
deleted file mode 100644
--- a/test/Feature/QueryLimitedSpec.hs
+++ /dev/null
@@ -1,69 +0,0 @@
-module Feature.QueryLimitedSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "Requesting many items with server limits(max-rows) enabled" $ do
-    it "restricts results" $
-      get "/items?order=id"
-        `shouldRespondWith`
-          [json| [{"id":1},{"id":2}] |]
-          { matchHeaders = ["Content-Range" <:> "0-1/*"] }
-
-    it "respects additional client limiting" $ do
-      request methodGet  "/items"
-          (rangeHdrs $ ByteRangeFromTo 0 0)
-          ""
-        `shouldRespondWith`
-          [json| [{"id":1}] |]
-          { matchHeaders = ["Content-Range" <:> "0-0/*"] }
-
-    it "works on all levels" $
-      get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
-        `shouldRespondWith`
-          [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
-          { matchHeaders = ["Content-Range" <:> "0-1/*"] }
-
-    it "succeeds in getting parent embeds despite the limit, see #647" $
-      get "/tasks?select=id,project:projects(id)&id=gt.5"
-        `shouldRespondWith`
-          [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
-          { matchHeaders = ["Content-Range" <:> "0-1/*"] }
-
-    it "can offset the parent embed, being consistent with the other embed types" $
-      get "/tasks?select=id,project:projects(id)&id=gt.5&project.offset=1"
-        `shouldRespondWith`
-          [json|[{"id":6,"project":null}, {"id":7,"project":null}]|]
-          { matchHeaders = ["Content-Range" <:> "0-1/*"] }
-
-    context "count=estimated" $ do
-      it "uses the query planner guess when query rows > maxRows" $
-        request methodHead "/getallprojects_view" [("Prefer", "count=estimated")] ""
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 206
-            , matchHeaders = ["Content-Range" <:> "0-1/2019"]
-            }
-
-      it "gives exact count when query rows <= maxRows" $
-        request methodHead "/getallprojects_view?id=lt.3" [("Prefer", "count=estimated")] ""
-          `shouldRespondWith`
-            ""
-            { matchHeaders = ["Content-Range" <:> "0-1/2"] }
-
-      it "only uses the query planner guess if it's indeed greater than the exact count" $
-        request methodHead "/get_projects_above_view" [("Prefer", "count=estimated")] ""
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 206
-            , matchHeaders = ["Content-Range" <:> "0-1/3"]
-            }
diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs
deleted file mode 100644
--- a/test/Feature/QuerySpec.hs
+++ /dev/null
@@ -1,1108 +0,0 @@
-module Feature.QuerySpec where
-
-import Network.Wai      (Application)
-import Network.Wai.Test (SResponse (simpleHeaders))
-
-import Network.HTTP.Types
-import Test.Hspec          hiding (pendingWith)
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
-                                   pgVersion112, pgVersion121)
-import Protolude                  hiding (get)
-import SpecHelper
-
-spec :: PgVersion -> SpecWith ((), Application)
-spec actualPgVersion = do
-
-  describe "Querying a table with a column called count" $
-    it "should not confuse count column with pg_catalog.count aggregate" $
-      get "/has_count_column" `shouldRespondWith` 200
-
-  describe "Querying a table with a column called t" $
-    it "should not conflict with internal postgrest table alias" $
-      get "/clashing_column?select=t" `shouldRespondWith` 200
-
-  describe "Querying a nonexistent table" $
-    it "causes a 404" $
-      get "/faketable" `shouldRespondWith` 404
-
-  describe "Filtering response" $ do
-    it "matches with equality" $
-      get "/items?id=eq.5"
-        `shouldRespondWith` [json| [{"id":5}] |]
-        { matchHeaders = ["Content-Range" <:> "0-0/*"] }
-
-    it "matches with equality using not operator" $
-      get "/items?id=not.eq.5&order=id"
-        `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
-        { matchHeaders = ["Content-Range" <:> "0-13/*"] }
-
-    it "matches with more than one condition using not operator" $
-      get "/simple_pk?k=like.*yx&extra=not.eq.u" `shouldRespondWith` "[]"
-
-    it "matches with inequality using not operator" $ do
-      get "/items?id=not.lt.14&order=id.asc"
-        `shouldRespondWith` [json| [{"id":14},{"id":15}] |]
-        { matchHeaders = ["Content-Range" <:> "0-1/*"] }
-      get "/items?id=not.gt.2&order=id.asc"
-        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
-        { matchHeaders = ["Content-Range" <:> "0-1/*"] }
-
-    it "matches items IN" $
-      get "/items?id=in.(1,3,5)"
-        `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
-        { matchHeaders = ["Content-Range" <:> "0-2/*"] }
-
-    it "matches items NOT IN using not operator" $
-      get "/items?id=not.in.(2,4,6,7,8,9,10,11,12,13,14,15)"
-        `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
-        { matchHeaders = ["Content-Range" <:> "0-2/*"] }
-
-    it "matches nulls using not operator" $
-      get "/no_pk?a=not.is.null" `shouldRespondWith`
-        [json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "matches nulls in varchar and numeric fields alike" $ do
-      get "/no_pk?a=is.null" `shouldRespondWith`
-        [json| [{"a": null, "b": null}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-      get "/nullable_integer?a=is.null" `shouldRespondWith` [json|[{"a":null}]|]
-
-    it "matches with trilean values" $ do
-      get "/chores?done=is.true" `shouldRespondWith`
-        [json| [{"id": 1, "name": "take out the garbage", "done": true }] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-      get "/chores?done=is.false" `shouldRespondWith`
-        [json| [{"id": 2, "name": "do the laundry", "done": false }] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-      get "/chores?done=is.unknown" `shouldRespondWith`
-        [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "fails if 'is' used and there's no null or trilean value" $ do
-      get "/chores?done=is.nil" `shouldRespondWith` 400
-      get "/chores?done=is.ok"  `shouldRespondWith` 400
-
-    it "matches with like" $ do
-      get "/simple_pk?k=like.*yx" `shouldRespondWith`
-        [json|[{"k":"xyyx","extra":"u"}]|]
-      get "/simple_pk?k=like.xy*" `shouldRespondWith`
-        [json|[{"k":"xyyx","extra":"u"}]|]
-      get "/simple_pk?k=like.*YY*" `shouldRespondWith`
-        [json|[{"k":"xYYx","extra":"v"}]|]
-
-    it "matches with like using not operator" $
-      get "/simple_pk?k=not.like.*yx" `shouldRespondWith`
-        [json|[{"k":"xYYx","extra":"v"}]|]
-
-    it "matches with ilike" $ do
-      get "/simple_pk?k=ilike.xy*&order=extra.asc" `shouldRespondWith`
-        [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith`
-        [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "matches with ilike using not operator" $
-      get "/simple_pk?k=not.ilike.xy*&order=extra.asc" `shouldRespondWith` "[]"
-
-    describe "Full text search operator" $ do
-      it "finds matches with to_tsquery" $
-        get "/tsearch?text_search_vector=fts.impossible" `shouldRespondWith`
-          [json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "can use lexeme boolean operators(&=%26, |=%7C, !) in to_tsquery" $ do
-        get "/tsearch?text_search_vector=fts.fun%26possible" `shouldRespondWith`
-          [json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/tsearch?text_search_vector=fts.impossible%7Cpossible" `shouldRespondWith`
-          [json| [
-          {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
-          {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/tsearch?text_search_vector=fts.fun%26!possible" `shouldRespondWith`
-          [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "finds matches with plainto_tsquery" $
-        get "/tsearch?text_search_vector=plfts.The%20Fat%20Rats" `shouldRespondWith`
-          [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      when (actualPgVersion >= pgVersion112) $ do
-        it "finds matches with websearch_to_tsquery" $
-            get "/tsearch?text_search_vector=wfts.The%20Fat%20Rats" `shouldRespondWith`
-                [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
-                { matchHeaders = [matchContentTypeJson] }
-
-        it "can use boolean operators(and, or, -) in websearch_to_tsquery" $ do
-          get "/tsearch?text_search_vector=wfts.fun%20and%20possible"
-            `shouldRespondWith`
-              [json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]
-              { matchHeaders = [matchContentTypeJson] }
-          get "/tsearch?text_search_vector=wfts.impossible%20or%20possible"
-            `shouldRespondWith`
-              [json| [
-                {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
-                {"text_search_vector": "'also':2 'fun':3 'possibl':8"}]
-                  |]
-              { matchHeaders = [matchContentTypeJson] }
-          get "/tsearch?text_search_vector=wfts.fun%20and%20-possible"
-            `shouldRespondWith`
-              [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]
-              { matchHeaders = [matchContentTypeJson] }
-
-      it "finds matches with different dictionaries" $ do
-        get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`
-          [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/tsearch?text_search_vector=plfts(french).amusant%20impossible" `shouldRespondWith`
-          [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-        when (actualPgVersion >= pgVersion112) $
-            get "/tsearch?text_search_vector=wfts(french).amusant%20impossible"
-                `shouldRespondWith`
-                  [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
-                  { matchHeaders = [matchContentTypeJson] }
-
-      it "can be negated with not operator" $ do
-        get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`
-          [json| [
-            {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
-            {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/tsearch?text_search_vector=not.fts(english).impossible%7Cfat%7Cfun" `shouldRespondWith`
-          [json| [
-            {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
-            {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/tsearch?text_search_vector=not.plfts.The%20Fat%20Rats" `shouldRespondWith`
-          [json| [
-            {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
-            {"text_search_vector": "'also':2 'fun':3 'possibl':8"},
-            {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
-            {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        when (actualPgVersion >= pgVersion112) $
-            get "/tsearch?text_search_vector=not.wfts(english).impossible%20or%20fat%20or%20fun"
-                `shouldRespondWith`
-                  [json| [
-                    {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
-                    {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
-                  { matchHeaders = [matchContentTypeJson] }
-
-      context "Use of the phraseto_tsquery function" $ do
-        it "finds matches" $
-          get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`
-            [json| [{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "finds matches with different dictionaries" $
-          get "/tsearch?text_search_vector=phfts(german).Art%20Spass" `shouldRespondWith`
-            [json| [{"text_search_vector": "'art':4 'spass':5 'unmog':7" }] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can be negated with not operator" $
-          get "/tsearch?text_search_vector=not.phfts(english).The%20Fat%20Cats" `shouldRespondWith`
-            [json| [
-              {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
-              {"text_search_vector": "'also':2 'fun':3 'possibl':8"},
-              {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
-              {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can be used with or query param" $
-          get "/tsearch?or=(text_search_vector.phfts(german).Art%20Spass, text_search_vector.phfts(french).amusant, text_search_vector.fts(english).impossible)" `shouldRespondWith`
-            [json|[
-              {"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
-              {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
-              {"text_search_vector": "'art':4 'spass':5 'unmog':7"}
-            ]|] { matchHeaders = [matchContentTypeJson] }
-
-    it "matches with computed column" $
-      get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
-        [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "order by computed column" $
-      get "/items?order=anti_id.desc" `shouldRespondWith`
-        [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "cannot access a computed column that is outside of the config schema" $
-      get "/items?always_false=is.false" `shouldRespondWith` 400
-
-    it "matches filtering nested items 2" $
-      get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*" `shouldRespondWith`
-        [json| {
-          "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
-          "message":"Could not find a relationship between projects and tasks2 in the schema cache"}|]
-        { matchStatus  = 400
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-    it "matches filtering nested items" $
-      get "/clients?select=id,projects(id,tasks(id,name))&projects.tasks.name=like.Design*" `shouldRespondWith`
-        [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "matches with cs operator" $
-      get "/complex_items?select=id&arr_data=cs.{2}" `shouldRespondWith`
-        [json|[{"id":2},{"id":3}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "matches with cd operator" $
-      get "/complex_items?select=id&arr_data=cd.{1,2,4}" `shouldRespondWith`
-        [json|[{"id":1},{"id":2}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-  describe "Shaping response with select parameter" $ do
-    it "selectStar works in absense of parameter" $
-      get "/complex_items?id=eq.3" `shouldRespondWith`
-        [json|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]
-
-    it "dash `-` in column names is accepted" $
-      get "/complex_items?id=eq.3&select=id,field-with_sep" `shouldRespondWith`
-        [json|[{"id":3,"field-with_sep":1}]|]
-
-    it "one simple column" $
-      get "/complex_items?select=id" `shouldRespondWith`
-        [json| [{"id":1},{"id":2},{"id":3}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "rename simple column" $
-      get "/complex_items?id=eq.1&select=myId:id" `shouldRespondWith`
-        [json| [{"myId":1}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "one simple column with casting (text)" $
-      get "/complex_items?select=id::text" `shouldRespondWith`
-        [json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "rename simple column with casting" $
-      get "/complex_items?id=eq.1&select=myId:id::text" `shouldRespondWith`
-        [json| [{"myId":"1"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "json column" $
-      get "/complex_items?id=eq.1&select=settings" `shouldRespondWith`
-        [json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "fails on bad casting (wrong cast type)" $
-      get "/complex_items?select=id::fakecolumntype"
-        `shouldRespondWith` [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]
-        { matchStatus  = 400
-        , matchHeaders = []
-        }
-
-    it "requesting parents and children" $
-      get "/projects?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`
-        [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting parent and renaming primary key" $
-      get "/projects?select=name,client:clients(clientId:id,name)" `shouldRespondWith`
-        [json|[
-          {"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},
-          {"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},
-          {"name":"IOS","client":{"name": "Apple", "clientId": 2}},
-          {"name":"OSX","client":{"name": "Apple", "clientId": 2}},
-          {"name":"Orphan","client":null}
-        ]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting parent and specifying/renaming one key of the composite primary key" $ do
-      get "/comments?select=*,users_tasks(userId:user_id)" `shouldRespondWith`
-        [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"userId": 2}}]|]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/comments?select=*,users_tasks(taskId:task_id)" `shouldRespondWith`
-        [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting parents and children while renaming them" $
-      get "/projects?id=eq.1&select=myId:id, name, project_client:clients(*), project_tasks:tasks(id, name)" `shouldRespondWith`
-        [json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting parents and filtering parent columns" $
-      get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`
-        [json|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
-
-    it "rows with missing parents are included" $
-      get "/projects?id=in.(1,5)&select=id,clients(id)" `shouldRespondWith`
-        [json|[{"id":1,"clients":{"id":1}},{"id":5,"clients":null}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "rows with no children return [] instead of null" $
-      get "/projects?id=in.(5)&select=id,tasks(id)" `shouldRespondWith`
-        [json|[{"id":5,"tasks":[]}]|]
-
-    it "requesting children 2 levels" $
-      get "/clients?id=eq.1&select=id,projects(id,tasks(id))" `shouldRespondWith`
-        [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting many<->many relation" $
-      get "/tasks?select=id,users(id)" `shouldRespondWith`
-        [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting many<->many relation with rename" $
-      get "/tasks?id=eq.1&select=id,theUsers:users(id)" `shouldRespondWith`
-        [json|[{"id":1,"theUsers":[{"id":1},{"id":3}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting many<->many relation reverse" $
-      get "/users?select=id,tasks(id)" `shouldRespondWith`
-        [json|[{"id":1,"tasks":[{"id":1},{"id":2},{"id":3},{"id":4}]},{"id":2,"tasks":[{"id":5},{"id":6},{"id":7}]},{"id":3,"tasks":[{"id":1},{"id":5}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting many<->many relation using composite key" $
-      get "/files?filename=eq.autoexec.bat&project_id=eq.1&select=filename,users_tasks(user_id,task_id)" `shouldRespondWith`
-        [json|[{"filename":"autoexec.bat","users_tasks":[{"user_id":1,"task_id":1},{"user_id":3,"task_id":1}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting data using many<->many relation defined by composite keys" $
-      get "/users_tasks?user_id=eq.1&task_id=eq.1&select=user_id,files(filename,content)" `shouldRespondWith`
-        [json|[{"user_id":1,"files":[{"filename":"command.com","content":"#include <unix.h>"},{"filename":"autoexec.bat","content":"@ECHO OFF"},{"filename":"README.md","content":"# make $$$!"}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting data using many<->many (composite keys) relation using hint" $
-      get "/users_tasks?user_id=eq.1&task_id=eq.1&select=user_id,files!touched_files(filename,content)" `shouldRespondWith`
-        [json|[{"user_id":1,"files":[{"filename":"command.com","content":"#include <unix.h>"},{"filename":"autoexec.bat","content":"@ECHO OFF"},{"filename":"README.md","content":"# make $$$!"}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "requesting children with composite key" $
-      get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments(content)" `shouldRespondWith`
-        [json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    describe "computed columns" $ do
-      it "computed column on table" $
-        get "/items?id=eq.1&select=id,always_true" `shouldRespondWith`
-          [json|[{"id":1,"always_true":true}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "computed column on rpc" $
-        get "/rpc/search?id=1&select=id,always_true" `shouldRespondWith`
-          [json|[{"id":1,"always_true":true}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "overloaded computed columns on both tables" $ do
-        get "/items?id=eq.1&select=id,computed_overload" `shouldRespondWith`
-          [json|[{"id":1,"computed_overload":true}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/items2?id=eq.1&select=id,computed_overload" `shouldRespondWith`
-          [json|[{"id":1,"computed_overload":true}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "overloaded computed column on rpc" $
-        get "/rpc/search?id=1&select=id,computed_overload" `shouldRespondWith`
-          [json|[{"id":1,"computed_overload":true}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-    when (actualPgVersion >= pgVersion110) $ do
-      describe "partitioned tables embedding" $ do
-        it "can request a table as parent from a partitioned table" $
-          get "/car_models?name=in.(DeLorean,Murcielago)&select=name,year,car_brands(name)&order=name.asc" `shouldRespondWith`
-            [json|
-              [{"name":"DeLorean","year":1981,"car_brands":{"name":"DMC"}},
-               {"name":"Murcielago","year":2001,"car_brands":{"name":"Lamborghini"}}] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can request partitioned tables as children from a table" $
-          get "/car_brands?select=name,car_models(name,year)&order=name.asc&car_models.order=name.asc" `shouldRespondWith`
-            [json|
-              [{"name":"DMC","car_models":[{"name":"DeLorean","year":1981}]},
-               {"name":"Ferrari","car_models":[{"name":"F310-B","year":1997}]},
-               {"name":"Lamborghini","car_models":[{"name":"Murcielago","year":2001},{"name":"Veneno","year":2013}]}] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-        when (actualPgVersion >= pgVersion121) $ do
-          it "can request tables as children from a partitioned table" $
-            get "/car_models?name=in.(DeLorean,F310-B)&select=name,year,car_racers(name)&order=name.asc" `shouldRespondWith`
-              [json|
-                [{"name":"DeLorean","year":1981,"car_racers":[]},
-                 {"name":"F310-B","year":1997,"car_racers":[{"name":"Michael Schumacher"}]}] |]
-              { matchHeaders = [matchContentTypeJson] }
-
-          it "can request a partitioned table as parent from a table" $
-            get "/car_racers?select=name,car_models(name,year)&order=name.asc" `shouldRespondWith`
-              [json|
-                [{"name":"Alain Prost","car_models":null},
-                 {"name":"Michael Schumacher","car_models":{"name":"F310-B","year":1997}}] |]
-              { matchHeaders = [matchContentTypeJson] }
-
-          it "can request partitioned tables as children from a partitioned table" $
-            get "/car_models?name=in.(DeLorean,Murcielago,Veneno)&select=name,year,car_model_sales(date,quantity)&order=name.asc" `shouldRespondWith`
-              [json|
-                [{"name":"DeLorean","year":1981,"car_model_sales":[{"date":"2021-01-14","quantity":7},{"date":"2021-01-15","quantity":9}]},
-                 {"name":"Murcielago","year":2001,"car_model_sales":[{"date":"2021-02-11","quantity":1},{"date":"2021-02-12","quantity":3}]},
-                 {"name":"Veneno","year":2013,"car_model_sales":[]}] |]
-              { matchHeaders = [matchContentTypeJson] }
-
-          it "can request a partitioned table as parent from a partitioned table" $ do
-            get "/car_model_sales?date=in.(2021-01-15,2021-02-11)&select=date,quantity,car_models(name,year)&order=date.asc" `shouldRespondWith`
-              [json|
-                [{"date":"2021-01-15","quantity":9,"car_models":{"name":"DeLorean","year":1981}},
-                 {"date":"2021-02-11","quantity":1,"car_models":{"name":"Murcielago","year":2001}}] |]
-              { matchHeaders = [matchContentTypeJson] }
-
-          it "can request many to many relationships between partitioned tables ignoring the intermediate table partitions" $
-            get "/car_models?select=name,year,car_dealers(name,city)&order=name.asc&limit=4" `shouldRespondWith`
-              [json|
-                [{"name":"DeLorean","year":1981,"car_dealers":[{"name":"Springfield Cars S.A.","city":"Springfield"}]},
-                 {"name":"F310-B","year":1997,"car_dealers":[]},
-                 {"name":"Murcielago","year":2001,"car_dealers":[{"name":"The Best Deals S.A.","city":"Franklin"}]},
-                 {"name":"Veneno","year":2013,"car_dealers":[]}] |]
-              { matchStatus  = 200
-              , matchHeaders = [matchContentTypeJson]
-              }
-
-          it "cannot request partitions as children from a partitioned table" $
-            get "/car_models?id=in.(1,2,4)&select=id,name,car_model_sales_202101(id)&order=id.asc" `shouldRespondWith`
-              [json|
-                {"hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
-                 "message":"Could not find a relationship between car_models and car_model_sales_202101 in the schema cache"} |]
-              { matchStatus  = 400
-              , matchHeaders = [matchContentTypeJson]
-              }
-
-          it "cannot request a partitioned table as parent from a partition" $
-            get "/car_model_sales_202101?select=id,name,car_models(id,name)&order=id.asc" `shouldRespondWith`
-              [json|
-                {"hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
-                 "message":"Could not find a relationship between car_model_sales_202101 and car_models in the schema cache"} |]
-              { matchStatus  = 400
-              , matchHeaders = [matchContentTypeJson]
-              }
-
-          it "cannot request a partition as parent from a partitioned table" $
-            get "/car_model_sales?id=in.(1,3,4)&select=id,name,car_models_default(id,name)&order=id.asc" `shouldRespondWith`
-              [json|
-                {"hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
-                 "message":"Could not find a relationship between car_model_sales and car_models_default in the schema cache"} |]
-              { matchStatus  = 400
-              , matchHeaders = [matchContentTypeJson]
-              }
-
-          it "cannot request partitioned tables as children from a partition" $
-            get "/car_models_default?select=id,name,car_model_sales(id,name)&order=id.asc" `shouldRespondWith`
-              [json|
-                {"hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
-                 "message":"Could not find a relationship between car_models_default and car_model_sales in the schema cache"} |]
-              { matchStatus  = 400
-              , matchHeaders = [matchContentTypeJson]
-              }
-
-    describe "view embedding" $ do
-      it "can detect fk relations through views to tables in the public schema" $
-        get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200
-
-      it "can detect fk relations through materialized views to tables in the public schema" $
-        get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200
-
-      it "can request two parents" $
-        get "/articleStars?select=createdAt,article:articles(id),user:users(name)&limit=1"
-          `shouldRespondWith`
-            [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"id": 1},"user":{"name": "Angela Martin"}}]|]
-
-      it "can detect relations in views from exposed schema that are based on tables in private schema and have columns renames" $
-        get "/articles?id=eq.1&select=id,articleStars(users(*))" `shouldRespondWith`
-          [json|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "works when requesting parents and children on views" $
-        get "/projects_view?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`
-          [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "works when requesting parents and children on views with renamed keys" $
-        get "/projects_view_alt?t_id=eq.1&select=t_id, name, clients(*), tasks(id, name)" `shouldRespondWith`
-          [json|[{"t_id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "detects parent relations when having many views of a private table" $ do
-        get "/books?select=title,author:authors(name)&id=eq.5" `shouldRespondWith`
-          [json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/forties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
-          [json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/fifties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
-          [json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/sixties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
-          [json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "can detect fk relations through multiple views recursively when all views are in api schema" $ do
-        get "/consumers_view_view?select=*,orders_view(*)" `shouldRespondWith` 200
-
-      it "works with views that have subselects" $
-        get "/authors_books_number?select=*,books(title)&id=eq.1" `shouldRespondWith`
-          [json|[ {"id":1, "name":"George Orwell","num_in_forties":1,"num_in_fifties":0,"num_in_sixties":0,"num_in_all_decades":1,
-                   "books":[{"title":"1984"}]} ]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "works with views that have case subselects" $
-        get "/authors_have_book_in_decade?select=*,books(title)&id=eq.3" `shouldRespondWith`
-          [json|[ {"id":3,"name":"Antoine de Saint-Exupéry","has_book_in_forties":true,"has_book_in_fifties":false,"has_book_in_sixties":false,
-                   "books":[{"title":"The Little Prince"}]} ]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "works with views that have subselect in the FROM clause" $
-        get "/forties_and_fifties_books?select=title,first_publisher,author:authors(name)&id=eq.1" `shouldRespondWith`
-          [json|[{"title":"1984","first_publisher":"Secker & Warburg","author":{"name":"George Orwell"}}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "works with views that have subselects in a function call" $
-        get "/authors_have_book_in_decade2?select=*,books(title)&id=eq.3"
-          `shouldRespondWith`
-            [json|[ {"id":3,"name":"Antoine de Saint-Exupéry","has_book_in_forties":true,"has_book_in_fifties":false,
-                     "has_book_in_sixties":false,"books":[{"title":"The Little Prince"}]} ]|]
-
-      it "works with views that have CTE" $
-        get "/odd_years_publications?select=title,publication_year,first_publisher,author:authors(name)&id=in.(1,2,3)" `shouldRespondWith`
-          [json|[
-            {"title":"1984","publication_year":1949,"first_publisher":"Secker & Warburg","author":{"name":"George Orwell"}},
-            {"title":"The Diary of a Young Girl","publication_year":1947,"first_publisher":"Contact Publishing","author":{"name":"Anne Frank"}},
-            {"title":"The Little Prince","publication_year":1947,"first_publisher":"Reynal & Hitchcock","author":{"name":"Antoine de Saint-Exupéry"}} ]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "works when having a capitalized table name and camelCase fk column" $
-        get "/foos?select=*,bars(*)" `shouldRespondWith` 200
-
-      it "works when embedding a view with a table that has a long compound pk" $ do
-        get "/player_view?select=id,contract(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`
-          [json|
-            [{"id":1,"contract":[{"purchase_price":10}]},
-             {"id":3,"contract":[{"purchase_price":30}]},
-             {"id":5,"contract":[{"purchase_price":50}]},
-             {"id":7,"contract":[]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/contract?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`
-          [json|
-            [{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},
-             {"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},
-             {"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "works when embedding a view with a view that referes to a table that has a long compound pk" $ do
-        get "/player_view?select=id,contract_view(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`
-          [json|
-            [{"id":1,"contract_view":[{"purchase_price":10}]},
-             {"id":3,"contract_view":[{"purchase_price":30}]},
-             {"id":5,"contract_view":[{"purchase_price":50}]},
-             {"id":7,"contract_view":[]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/contract_view?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`
-          [json|
-            [{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},
-             {"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},
-             {"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "can embed a view that has group by" $
-        get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`
-          [json|
-            [{"number_of_projects":1,"client":null},
-             {"number_of_projects":2,"client":{"name":"Microsoft"}},
-             {"number_of_projects":2,"client":{"name":"Apple"}}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "can embed a view that has a subselect containing a select in a where" $
-        get "/authors_w_entities?select=name,entities,books(title)&id=eq.1" `shouldRespondWith`
-          [json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-    describe "aliased embeds" $ do
-      it "works with child relation" $
-        get "/space?select=id,zones:zone(id,name),stores:zone(id,name)&zones.zone_type_id=eq.2&stores.zone_type_id=eq.3" `shouldRespondWith`
-          [json|[
-            { "id":1,
-              "zones": [ {"id":1,"name":"zone 1"}, {"id":2,"name":"zone 2"}],
-              "stores": [ {"id":3,"name":"store 3"}, {"id":4,"name":"store 4"}]}
-          ]|] { matchHeaders = [matchContentTypeJson] }
-
-      it "works with many to many relation" $
-        get "/users?select=id,designTasks:tasks(id,name),codeTasks:tasks(id,name)&designTasks.name=like.*Design*&codeTasks.name=like.*Code*" `shouldRespondWith`
-          [json|[
-             { "id":1,
-               "designTasks":[ { "id":1, "name":"Design w7" }, { "id":3, "name":"Design w10" } ],
-               "codeTasks":[ { "id":2, "name":"Code w7" }, { "id":4, "name":"Code w10" } ] },
-             { "id":2,
-               "designTasks":[ { "id":5, "name":"Design IOS" }, { "id":7, "name":"Design OSX" } ],
-               "codeTasks":[ { "id":6, "name":"Code IOS" } ] },
-             { "id":3,
-               "designTasks":[ { "id":1, "name":"Design w7" }, { "id":5, "name":"Design IOS" } ],
-               "codeTasks":[ ] }
-          ]|] { matchHeaders = [matchContentTypeJson] }
-
-      it "works with an aliased child plus non aliased child" $
-        get "/projects?select=id,name,designTasks:tasks(name,users(id,name))&designTasks.name=like.*Design*&designTasks.users.id=in.(1,2)" `shouldRespondWith`
-          [json|[
-            {
-              "id":1, "name":"Windows 7",
-              "designTasks":[ { "name":"Design w7", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },
-            {
-              "id":2, "name":"Windows 10",
-              "designTasks":[ { "name":"Design w10", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },
-            {
-              "id":3, "name":"IOS",
-              "designTasks":[ { "name":"Design IOS", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },
-            {
-              "id":4, "name":"OSX",
-              "designTasks":[ { "name":"Design OSX", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },
-            {
-              "id":5, "name":"Orphan",
-              "designTasks":[ ] }
-          ]|] { matchHeaders = [matchContentTypeJson] }
-
-      it "works with two aliased children embeds plus and/or" $
-        get "/entities?select=id,children:child_entities(id,gChildren:grandchild_entities(id))&children.and=(id.in.(1,2,3))&children.gChildren.or=(id.eq.1,id.eq.2)" `shouldRespondWith`
-          [json|[
-            { "id":1,
-              "children":[
-                {"id":1,"gChildren":[{"id":1}, {"id":2}]},
-                {"id":2,"gChildren":[]}]},
-            { "id":2,
-              "children":[
-                {"id":3,"gChildren":[]}]},
-            { "id":3,"children":[]},
-            { "id":4,"children":[]}
-          ]|] { matchHeaders = [matchContentTypeJson] }
-
-  describe "ordering response" $ do
-    it "by a column asc" $
-      get "/items?id=lte.2&order=id.asc"
-        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
-        { matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-1/*"]
-        }
-
-
-    it "by a column desc" $
-      get "/items?id=lte.2&order=id.desc"
-        `shouldRespondWith` [json| [{"id":2},{"id":1}] |]
-        { matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-1/*"]
-        }
-
-    it "by a column with nulls first" $
-      get "/no_pk?order=a.nullsfirst"
-        `shouldRespondWith` [json| [{"a":null,"b":null},
-                              {"a":"1","b":"0"},
-                              {"a":"2","b":"0"}
-                              ] |]
-        { matchStatus = 200
-        , matchHeaders = ["Content-Range" <:> "0-2/*"]
-        }
-
-    it "by a column asc with nulls last" $
-      get "/no_pk?order=a.asc.nullslast"
-        `shouldRespondWith` [json| [{"a":"1","b":"0"},
-                              {"a":"2","b":"0"},
-                              {"a":null,"b":null}] |]
-        { matchStatus = 200
-        , matchHeaders = ["Content-Range" <:> "0-2/*"]
-        }
-
-    it "by a column desc with nulls first" $
-      get "/no_pk?order=a.desc.nullsfirst"
-        `shouldRespondWith` [json| [{"a":null,"b":null},
-                              {"a":"2","b":"0"},
-                              {"a":"1","b":"0"}] |]
-        { matchStatus = 200
-        , matchHeaders = ["Content-Range" <:> "0-2/*"]
-        }
-
-    it "by a column desc with nulls last" $
-      get "/no_pk?order=a.desc.nullslast"
-        `shouldRespondWith` [json| [{"a":"2","b":"0"},
-                              {"a":"1","b":"0"},
-                              {"a":null,"b":null}] |]
-        { matchStatus = 200
-        , matchHeaders = ["Content-Range" <:> "0-2/*"]
-        }
-
-    it "by two columns with nulls and direction specified" $
-      get "/projects?select=client_id,id,name&order=client_id.desc.nullslast,id.desc"
-        `shouldRespondWith` [json|
-          [{"client_id":2,"id":4,"name":"OSX"},
-           {"client_id":2,"id":3,"name":"IOS"},
-           {"client_id":1,"id":2,"name":"Windows 10"},
-           {"client_id":1,"id":1,"name":"Windows 7"},
-           {"client_id":null,"id":5,"name":"Orphan"}]
-        |]
-        { matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-4/*"]
-        }
-
-    it "by a column with no direction or nulls specified" $
-      get "/items?id=lte.2&order=id"
-        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
-        { matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-1/*"]
-        }
-
-    it "without other constraints" $
-      get "/items?order=id.asc" `shouldRespondWith` 200
-
-    it "ordering embeded entities" $
-      get "/projects?id=eq.1&select=id, name, tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`
-        [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "ordering embeded entities with alias" $
-      get "/projects?id=eq.1&select=id, name, the_tasks:tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`
-        [json|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "ordering embeded entities, two levels" $
-      get "/projects?id=eq.1&select=id, name, tasks(id, name, users(id, name))&tasks.order=name.asc&tasks.users.order=name.desc" `shouldRespondWith`
-        [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7","users":[{"id":1,"name":"Angela Martin"}]},{"id":1,"name":"Design w7","users":[{"id":3,"name":"Dwight Schrute"},{"id":1,"name":"Angela Martin"}]}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "ordering embeded parents does not break things" $
-      get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`
-        [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]
-
-    context "order syntax errors" $ do
-      it "gives meaningful error messages when asc/desc/nulls{first,last} are misspelled" $ do
-        get "/items?order=id.ac" `shouldRespondWith`
-          [json|{"details":"unexpected \"c\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.ac)\" (line 1, column 4)"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson]
-          }
-        get "/items?order=id.descc" `shouldRespondWith`
-          [json|{"details":"unexpected 'c' expecting delimiter (.), \",\" or end of input","message":"\"failed to parse order (id.descc)\" (line 1, column 8)"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson]
-          }
-        get "/items?order=id.nulsfist" `shouldRespondWith`
-          [json|{"details":"unexpected \"n\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.nulsfist)\" (line 1, column 4)"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson]
-          }
-        get "/items?order=id.nullslasttt" `shouldRespondWith`
-          [json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.nullslasttt)\" (line 1, column 13)"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson]
-          }
-        get "/items?order=id.smth34" `shouldRespondWith`
-          [json|{"details":"unexpected \"s\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.smth34)\" (line 1, column 4)"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-      it "gives meaningful error messages when nulls{first,last} are misspelled after asc/desc" $ do
-        get "/items?order=id.asc.nlsfst" `shouldRespondWith`
-          [json|{"details":"unexpected \"l\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.nlsfst)\" (line 1, column 8)"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson]
-          }
-        get "/items?order=id.asc.nullslasttt" `shouldRespondWith`
-          [json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.asc.nullslasttt)\" (line 1, column 17)"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson]
-          }
-        get "/items?order=id.asc.smth34" `shouldRespondWith`
-          [json|{"details":"unexpected \"s\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.smth34)\" (line 1, column 8)"}|]
-          { matchStatus  = 400
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-  describe "Accept headers" $ do
-    it "should respond an unknown accept type with 415" $
-      request methodGet "/simple_pk"
-              (acceptHdrs "text/unknowntype") ""
-        `shouldRespondWith`
-        [json|{"message":"None of these Content-Types are available: text/unknowntype"}|]
-        { matchStatus  = 415
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-    it "should respond correctly to */* in accept header" $
-      request methodGet "/simple_pk"
-              (acceptHdrs "*/*") ""
-        `shouldRespondWith` 200
-
-    it "*/* should rescue an unknown type" $
-      request methodGet "/simple_pk"
-              (acceptHdrs "text/unknowntype, */*") ""
-        `shouldRespondWith` 200
-
-    it "specific available preference should override */*" $ do
-      r <- request methodGet "/simple_pk"
-              (acceptHdrs "text/csv, */*") ""
-      liftIO $ do
-        let respHeaders = simpleHeaders r
-        respHeaders `shouldSatisfy` matchHeader
-          "Content-Type" "text/csv; charset=utf-8"
-
-    it "honors client preference even when opposite of server preference" $ do
-      r <- request methodGet "/simple_pk"
-              (acceptHdrs "text/csv, application/json") ""
-      liftIO $ do
-        let respHeaders = simpleHeaders r
-        respHeaders `shouldSatisfy` matchHeader
-          "Content-Type" "text/csv; charset=utf-8"
-
-    it "should respond correctly to multiple types in accept header" $
-      request methodGet "/simple_pk"
-              (acceptHdrs "text/unknowntype, text/csv") ""
-        `shouldRespondWith` 200
-
-    it "should respond with CSV to 'text/csv' request" $
-      request methodGet "/simple_pk"
-              (acceptHdrs "text/csv; version=1") ""
-        `shouldRespondWith` "k,extra\nxyyx,u\nxYYx,v"
-        { matchStatus  = 200
-        , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
-        }
-
-  describe "Canonical location" $ do
-    it "Sets Content-Location with alphabetized params" $
-      get "/no_pk?b=eq.1&a=eq.1"
-        `shouldRespondWith` "[]"
-        { matchStatus  = 200
-        , matchHeaders = ["Content-Location" <:> "/no_pk?a=eq.1&b=eq.1"]
-        }
-
-    it "Omits question mark when there are no params" $ do
-      r <- get "/simple_pk"
-      liftIO $ do
-        let respHeaders = simpleHeaders r
-        respHeaders `shouldSatisfy` matchHeader
-          "Content-Location" "/simple_pk"
-
-  describe "weird requests" $ do
-    it "can query as normal" $ do
-      get "/Escap3e;" `shouldRespondWith`
-        [json| [{"so6meIdColumn":1},{"so6meIdColumn":2},{"so6meIdColumn":3},{"so6meIdColumn":4},{"so6meIdColumn":5}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/ghostBusters" `shouldRespondWith`
-        [json| [{"escapeId":1},{"escapeId":3},{"escapeId":5}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "fails if an operator is not given" $
-      get "/ghostBusters?id=0" `shouldRespondWith` [json| {"details":"unexpected \"0\" expecting \"not\" or operator (eq, gt, ...)","message":"\"failed to parse filter (0)\" (line 1, column 1)"} |]
-        { matchStatus  = 400
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-    it "will embed a collection" $
-      get "/Escap3e;?select=ghostBusters(*)" `shouldRespondWith`
-        [json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "will select and filter a column that has spaces" $
-      get "/Server%20Today?select=Just%20A%20Server%20Model&Just%20A%20Server%20Model=like.*91*" `shouldRespondWith`
-        [json|[
-          {"Just A Server Model":" IBM,9113-550 (P5-550)"},
-          {"Just A Server Model":" IBM,9113-550 (P5-550)"},
-          {"Just A Server Model":" IBM,9131-52A (P5-52A)"},
-          {"Just A Server Model":" IBM,9133-55A (P5-55A)"}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "will select and filter a quoted column that has PostgREST reserved characters" $
-      get "/pgrst_reserved_chars?select=%22:arr-%3Eow::cast%22,%22(inside,parens)%22,%22a.dotted.column%22,%22%20%20col%20%20w%20%20space%20%20%22&%22*id*%22=eq.1" `shouldRespondWith`
-        [json|[{":arr->ow::cast":" arrow-1 ","(inside,parens)":" parens-1 ","a.dotted.column":" dotted-1 ","  col  w  space  ":" space-1"}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-  context "binary output" $ do
-    it "can query if a single column is selected" $
-      request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
-        `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
-        { matchStatus = 200
-        , matchHeaders = ["Content-Type" <:> "application/octet-stream"]
-        }
-
-    it "can get raw output with Accept: text/plain" $
-      request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") ""
-        `shouldRespondWith` "Windows 7"
-        { matchStatus = 200
-        , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
-        }
-
-    it "fails if a single column is not selected" $ do
-      request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
-        `shouldRespondWith`
-          [json| {"message":"application/octet-stream requested but more than one column was selected"} |]
-          { matchStatus = 406 }
-
-      request methodGet "/images?select=*&name=eq.A.png"
-          (acceptHdrs "application/octet-stream")
-          ""
-        `shouldRespondWith`
-          [json| {"message":"application/octet-stream requested but more than one column was selected"} |]
-          { matchStatus = 406 }
-
-      request methodGet "/images?name=eq.A.png"
-          (acceptHdrs "application/octet-stream")
-          ""
-        `shouldRespondWith`
-          [json| {"message":"application/octet-stream requested but more than one column was selected"} |]
-          { matchStatus = 406 }
-
-    it "concatenates results if more than one row is returned" $
-      request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (acceptHdrs "application/octet-stream") ""
-        `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
-        { matchStatus = 200
-        , matchHeaders = ["Content-Type" <:> "application/octet-stream"]
-        }
-
-  describe "values with quotes in IN and NOT IN" $ do
-    it "succeeds when only quoted values are present" $ do
-      get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`
-        [json| [{"name":"Hebdon, John"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")" `shouldRespondWith`
-        [json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")&limit=3" `shouldRespondWith`
-        [json| [ { "name": "David White" }, { "name": "Larry Thompson" }, { "name": "Double O Seven(007)" }] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "succeeds w/ and w/o quoted values" $ do
-      get "/w_or_wo_comma_names?name=in.(David White,\"Hebdon, John\")" `shouldRespondWith`
-        [json| [{"name":"Hebdon, John"},{"name":"David White"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\")&limit=3" `shouldRespondWith`
-        [json| [ { "name": "Williams, Mary" }, { "name": "David White" }, { "name": "Double O Seven(007)" }] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/w_or_wo_comma_names?name=in.(\"Double O Seven(007)\")" `shouldRespondWith`
-        [json| [{"name":"Double O Seven(007)"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    context "escaped chars" $ do
-      it "accepts escaped double quotes" $
-        get "/w_or_wo_comma_names?name=in.(\"Double\\\"Quote\\\"McGraw\\\"\")" `shouldRespondWith`
-          [json| [ { "name": "Double\"Quote\"McGraw\"" } ] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "accepts escaped backslashes" $ do
-        get "/w_or_wo_comma_names?name=in.(\"\\\\\")" `shouldRespondWith`
-          [json| [{ "name": "\\" }] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/w_or_wo_comma_names?name=in.(\"/\\\\Slash/\\\\Beast/\\\\\")" `shouldRespondWith`
-          [json| [ { "name": "/\\Slash/\\Beast/\\" } ] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "passes any escaped char as the same char" $
-        get "/w_or_wo_comma_names?name=in.(\"D\\a\\vid W\\h\\ite\")" `shouldRespondWith`
-          [json| [{ "name": "David White" }] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-  describe "IN values without quotes" $ do
-    it "accepts single double quotes as values" $ do
-      get "/w_or_wo_comma_names?name=in.(\")" `shouldRespondWith`
-        [json| [{ "name": "\"" }] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/w_or_wo_comma_names?name=in.(Double\"Quote\"McGraw\")" `shouldRespondWith`
-        [json| [ { "name": "Double\"Quote\"McGraw\"" } ] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-    it "accepts backslashes as values" $ do
-      get "/w_or_wo_comma_names?name=in.(\\)" `shouldRespondWith`
-        [json| [{ "name": "\\" }] |]
-        { matchHeaders = [matchContentTypeJson] }
-      get "/w_or_wo_comma_names?name=in.(/\\Slash/\\Beast/\\)" `shouldRespondWith`
-        [json| [ { "name": "/\\Slash/\\Beast/\\" } ] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-  describe "IN and NOT IN empty set" $ do
-    context "returns an empty result for IN when no value is present" $ do
-      it "works for integer" $
-        get "/items_with_different_col_types?int_data=in.()" `shouldRespondWith`
-          [json| [] |] { matchHeaders = [matchContentTypeJson] }
-      it "works for text" $
-        get "/items_with_different_col_types?text_data=in.()" `shouldRespondWith`
-          [json| [] |] { matchHeaders = [matchContentTypeJson] }
-      it "works for bool" $
-        get "/items_with_different_col_types?bool_data=in.()" `shouldRespondWith`
-          [json| [] |] { matchHeaders = [matchContentTypeJson] }
-      it "works for bytea" $
-        get "/items_with_different_col_types?bin_data=in.()" `shouldRespondWith`
-          [json| [] |] { matchHeaders = [matchContentTypeJson] }
-      it "works for char" $
-        get "/items_with_different_col_types?char_data=in.()" `shouldRespondWith`
-          [json| [] |] { matchHeaders = [matchContentTypeJson] }
-      it "works for date" $
-        get "/items_with_different_col_types?date_data=in.()" `shouldRespondWith`
-          [json| [] |] { matchHeaders = [matchContentTypeJson] }
-      it "works for real" $
-        get "/items_with_different_col_types?real_data=in.()" `shouldRespondWith`
-          [json| [] |] { matchHeaders = [matchContentTypeJson] }
-      it "works for time" $
-        get "/items_with_different_col_types?time_data=in.()" `shouldRespondWith`
-          [json| [] |] { matchHeaders = [matchContentTypeJson] }
-
-    it "returns all results for not.in when no value is present" $
-      get "/items_with_different_col_types?int_data=not.in.()&select=int_data" `shouldRespondWith`
-        [json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }
-
-    it "returns an empty result ignoring spaces" $
-      get "/items_with_different_col_types?int_data=in.(    )" `shouldRespondWith`
-        [json| [] |] { matchHeaders = [matchContentTypeJson] }
-
-    it "only returns an empty result set if the in value is empty" $
-      get "/items_with_different_col_types?int_data=in.( ,3,4)"
-        `shouldRespondWith` (
-        if actualPgVersion >= pgVersion121 then
-        [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"\""} |]
-        else
-        [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
-                            )
-        { matchStatus = 400
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-  describe "Embedding when column name = table name" $ do
-    it "works with child embeds" $
-      get "/being?select=*,descendant(*)&limit=1" `shouldRespondWith`
-        [json|[{"being":1,"descendant":[{"descendant":1,"being":1},{"descendant":2,"being":1},{"descendant":3,"being":1}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-    it "works with many to many embeds" $
-      get "/being?select=*,part(*)&limit=1" `shouldRespondWith`
-        [json|[{"being":1,"part":[{"part":1}]}]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-  describe "Foreign table" $ do
-    it "can be queried by using regular filters" $
-      get "/projects_dump?id=in.(1,2,3)" `shouldRespondWith`
-        [json| [{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}, {"id":3,"name":"IOS","client_id":2}]|]
-        { matchHeaders = [matchContentTypeJson] }
-    it "can be queried with select, order and limit" $
-      get "/projects_dump?select=id,name&order=id.desc&limit=3" `shouldRespondWith`
-        [json| [{"id":5,"name":"Orphan"}, {"id":4,"name":"OSX"}, {"id":3,"name":"IOS"}] |]
-        { matchHeaders = [matchContentTypeJson] }
-
-  it "cannot use ltree(in public schema) extension operators if no extra search path added" $
-    get "/ltree_sample?path=cd.Top.Science.Astronomy" `shouldRespondWith` 400
-
-  context "VIEW that has a source FK based on a UNIQUE key" $
-    it "can be embedded" $
-      get "/referrals?select=site,link:pages(url)" `shouldRespondWith`
-        [json| [
-         {"site":"github.com",     "link":{"url":"http://postgrest.org/en/v6.0/api.html"}},
-         {"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}
-        ]|]
-        { matchHeaders = [matchContentTypeJson] }
-
-  it "shouldn't produce a Content-Profile header since only a single schema is exposed" $ do
-    r <- get "/items"
-    liftIO $ do
-      let respHeaders = simpleHeaders r
-      respHeaders `shouldSatisfy` noProfileHeader
diff --git a/test/Feature/RangeSpec.hs b/test/Feature/RangeSpec.hs
deleted file mode 100644
--- a/test/Feature/RangeSpec.hs
+++ /dev/null
@@ -1,352 +0,0 @@
-module Feature.RangeSpec where
-
-import qualified Data.ByteString.Lazy as BL
-
-import Network.Wai      (Application)
-import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude  hiding (get)
-import SpecHelper
-
-defaultRange :: BL.ByteString
-defaultRange = [json| { "min": 0, "max": 15 } |]
-
-emptyRange :: BL.ByteString
-emptyRange = [json| { "min": 2, "max": 2 } |]
-
-spec :: SpecWith ((), Application)
-spec = do
-  describe "POST /rpc/getitemrange" $ do
-    context "without range headers" $ do
-      context "with response under server size limit" $
-        it "returns whole range with status 200" $
-           post "/rpc/getitemrange" defaultRange `shouldRespondWith` 200
-
-      context "when I don't want the count" $ do
-        it "returns range Content-Range with */* for empty range" $
-          request methodPost "/rpc/getitemrange" [] emptyRange
-            `shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}
-
-        it "returns range Content-Range with range/*" $
-          post "/rpc/getitemrange?order=id"
-              defaultRange
-            `shouldRespondWith`
-              [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
-              { matchHeaders = ["Content-Range" <:> "0-14/*"] }
-
-    context "with range headers" $ do
-      context "of acceptable range" $ do
-        it "succeeds with partial content" $ do
-          r <- request methodPost  "/rpc/getitemrange"
-                       (rangeHdrs $ ByteRangeFromTo 0 1) defaultRange
-          liftIO $ do
-            simpleHeaders r `shouldSatisfy`
-              matchHeader "Content-Range" "0-1/*"
-            simpleStatus r `shouldBe` ok200
-
-        it "understands open-ended ranges" $
-          request methodPost "/rpc/getitemrange"
-                  (rangeHdrs $ ByteRangeFrom 0) defaultRange
-            `shouldRespondWith` 200
-
-        it "returns an empty body when there are no results" $
-          request methodPost "/rpc/getitemrange"
-                  (rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
-            `shouldRespondWith` "[]"
-            { matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-        it "allows one-item requests" $ do
-          r <- request methodPost  "/rpc/getitemrange"
-                       (rangeHdrs $ ByteRangeFromTo 0 0) defaultRange
-          liftIO $ do
-            simpleHeaders r `shouldSatisfy`
-              matchHeader "Content-Range" "0-0/*"
-            simpleStatus r `shouldBe` ok200
-
-        it "handles ranges beyond collection length via truncation" $ do
-          r <- request methodPost  "/rpc/getitemrange"
-                       (rangeHdrs $ ByteRangeFromTo 10 100) defaultRange
-          liftIO $ do
-            simpleHeaders r `shouldSatisfy`
-              matchHeader "Content-Range" "10-14/*"
-            simpleStatus r `shouldBe` ok200
-
-      context "of invalid range" $ do
-        it "fails with 416 for offside range" $
-          request methodPost  "/rpc/getitemrange"
-                  (rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
-            `shouldRespondWith` 416
-
-        it "refuses a range with nonzero start when there are no items" $
-          request methodPost "/rpc/getitemrange"
-                  (rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
-            `shouldRespondWith` "[]"
-            { matchStatus  = 416
-            , matchHeaders = ["Content-Range" <:> "*/0"]
-            }
-
-        it "refuses a range requesting start past last item" $
-          request methodPost "/rpc/getitemrange"
-                  (rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
-            `shouldRespondWith` "[]"
-            { matchStatus  = 416
-            , matchHeaders = ["Content-Range" <:> "*/15"]
-            }
-
-  describe "GET /items" $ do
-    context "without range headers" $ do
-      context "with response under server size limit" $
-        it "returns whole range with status 200" $
-          get "/items" `shouldRespondWith` 200
-
-      context "when I don't want the count" $ do
-        it "returns range Content-Range with /*" $
-          request methodGet "/menagerie"
-                  [("Prefer", "count=none")] ""
-            `shouldRespondWith` "[]"
-            { matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-        it "returns range Content-Range with range/*" $
-          request methodGet "/items?order=id"
-                  [("Prefer", "count=none")] ""
-            `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
-            { matchHeaders = ["Content-Range" <:> "0-14/*"] }
-
-        it "returns range Content-Range with range/* even using other filters" $
-          request methodGet "/items?id=eq.1&order=id"
-                  [("Prefer", "count=none")] ""
-            `shouldRespondWith` [json| [{"id":1}] |]
-            { matchHeaders = ["Content-Range" <:> "0-0/*"] }
-
-    context "with limit/offset parameters" $ do
-      it "no parameters return everything" $
-        get "/items?select=id&order=id.asc"
-          `shouldRespondWith`
-          [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "0-14/*"]
-          }
-      it "top level limit with parameter" $
-        get "/items?select=id&order=id.asc&limit=3"
-          `shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|]
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "0-2/*"]
-          }
-      it "headers override get parameters" $
-        request methodGet  "/items?select=id&order=id.asc&limit=3"
-                     (rangeHdrs $ ByteRangeFromTo 0 1) ""
-          `shouldRespondWith` [json|[{"id":1},{"id":2}]|]
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "0-1/*"]
-          }
-
-      it "limit works on all levels" $
-        get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
-          `shouldRespondWith`
-          [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "0-0/*"]
-          }
-
-      it "limit and offset works on first level" $ do
-        get "/items?select=id&order=id.asc&limit=3&offset=2"
-          `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "2-4/*"]
-          }
-        request methodHead "/items?select=id&order=id.asc&limit=3&offset=2" [] mempty
-          `shouldRespondWith` ""
-          { matchStatus  = 200
-          , matchHeaders = ["Content-Range" <:> "2-4/*"]
-          }
-
-      it "succeeds if offset equals 0 as a no-op" $
-        get "/items?select=id&offset=0&order=id"
-          `shouldRespondWith`
-            [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
-            { matchHeaders = ["Content-Range" <:> "0-14/*"] }
-
-      it "succeeds if offset is negative as a no-op" $
-        get "/items?select=id&offset=-4&order=id"
-          `shouldRespondWith`
-            [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
-            { matchHeaders = ["Content-Range" <:> "0-14/*"] }
-
-      it "fails if limit equals 0" $
-        get "/items?select=id&limit=0"
-          `shouldRespondWith` [json|{"message":"HTTP Range error"}|]
-          { matchStatus  = 416
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-      it "fails if limit is negative" $
-        get "/items?select=id&limit=-1"
-          `shouldRespondWith` [json|{"message":"HTTP Range error"}|]
-          { matchStatus  = 416
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-    context "when count=planned" $ do
-      it "obtains a filtered range" $ do
-        request methodGet "/items?select=id&id=gt.8"
-            [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            [json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|]
-            { matchStatus  = 206
-            , matchHeaders = ["Content-Range" <:> "0-6/8"]
-            }
-
-        request methodGet "/child_entities?select=id&id=gt.3"
-            [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            [json|[{"id":4}, {"id":5}, {"id":6}]|]
-            { matchStatus  = 206
-            , matchHeaders = ["Content-Range" <:> "0-2/4"]
-            }
-
-        request methodGet "/getallprojects_view?select=id&id=lt.3"
-            [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            [json|[{"id":1}, {"id":2}]|]
-            { matchStatus  = 206
-            , matchHeaders = ["Content-Range" <:> "0-1/673"]
-            }
-
-      it "obtains the full range" $ do
-        request methodHead "/items"
-            [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "0-14/15"]
-            }
-
-        request methodHead "/child_entities"
-            [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "0-5/6"]
-            }
-
-        request methodHead "/getallprojects_view"
-            [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 206
-            , matchHeaders = ["Content-Range" <:> "0-4/2019"]
-            }
-
-      it "ignores limit/offset on the planned count" $ do
-        request methodHead "/items?limit=2&offset=3"
-            [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 206
-            , matchHeaders = ["Content-Range" <:> "3-4/15"]
-            }
-
-        request methodHead "/child_entities?limit=2"
-           [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 206
-            , matchHeaders = ["Content-Range" <:> "0-1/6"]
-            }
-
-        request methodHead "/getallprojects_view?limit=2"
-            [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 206
-            , matchHeaders = ["Content-Range" <:> "0-1/2019"]
-            }
-
-      it "works with two levels" $
-        request methodHead "/child_entities?select=*,entities(*)"
-            [("Prefer", "count=planned")]
-            ""
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "0-5/6"]
-            }
-
-    context "with range headers" $ do
-      context "of acceptable range" $ do
-        it "succeeds with partial content" $ do
-          r <- request methodGet  "/items"
-                       (rangeHdrs $ ByteRangeFromTo 0 1) ""
-          liftIO $ do
-            simpleHeaders r `shouldSatisfy`
-              matchHeader "Content-Range" "0-1/*"
-            simpleStatus r `shouldBe` ok200
-
-        it "understands open-ended ranges" $
-          request methodGet "/items"
-                  (rangeHdrs $ ByteRangeFrom 0) ""
-            `shouldRespondWith` 200
-
-        it "returns an empty body when there are no results" $
-          request methodGet "/menagerie"
-                  (rangeHdrs $ ByteRangeFromTo 0 1) ""
-            `shouldRespondWith` "[]"
-            { matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-        it "allows one-item requests" $ do
-          r <- request methodGet  "/items"
-                       (rangeHdrs $ ByteRangeFromTo 0 0) ""
-          liftIO $ do
-            simpleHeaders r `shouldSatisfy`
-              matchHeader "Content-Range" "0-0/*"
-            simpleStatus r `shouldBe` ok200
-
-        it "handles ranges beyond collection length via truncation" $ do
-          r <- request methodGet  "/items"
-                       (rangeHdrs $ ByteRangeFromTo 10 100) ""
-          liftIO $ do
-            simpleHeaders r `shouldSatisfy`
-              matchHeader "Content-Range" "10-14/*"
-            simpleStatus r `shouldBe` ok200
-
-      context "of invalid range" $ do
-        it "fails with 416 for offside range" $
-          request methodGet  "/items"
-                  (rangeHdrs $ ByteRangeFromTo 1 0) ""
-            `shouldRespondWith` 416
-
-        it "refuses a range with nonzero start when there are no items" $
-          request methodGet "/menagerie"
-                  (rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
-            `shouldRespondWith` "[]"
-            { matchStatus  = 416
-            , matchHeaders = ["Content-Range" <:> "*/0"]
-            }
-
-        it "refuses a range requesting start past last item" $
-          request methodGet "/items"
-                  (rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
-            `shouldRespondWith` "[]"
-            { matchStatus  = 416
-            , matchHeaders = ["Content-Range" <:> "*/15"]
-            }
diff --git a/test/Feature/RawOutputTypesSpec.hs b/test/Feature/RawOutputTypesSpec.hs
deleted file mode 100644
--- a/test/Feature/RawOutputTypesSpec.hs
+++ /dev/null
@@ -1,33 +0,0 @@
-module Feature.RawOutputTypesSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude
-import SpecHelper (acceptHdrs)
-
-spec :: SpecWith ((), Application)
-spec = describe "When raw-media-types config variable is missing or left empty" $ do
-  let firefoxAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
-      chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
-  it "responds json to a GET request with Firefox Accept headers" $
-    request methodGet "/items?id=eq.1" firefoxAcceptHdrs ""
-      `shouldRespondWith` [json| [{"id":1}] |]
-        { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
-  it "responds json to a GET request with Chrome Accept headers" $
-    request methodGet "/items?id=eq.1" chromeAcceptHdrs ""
-      `shouldRespondWith` [json| [{"id":1}] |]
-        { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
-
-  it "responds json to a GET request to RPC with Firefox Accept headers" $
-    request methodGet "/rpc/get_projects_below?id=3" firefoxAcceptHdrs ""
-      `shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
-        { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
-  it "responds json to a GET request to RPC with Chrome Accept headers" $
-    request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
-      `shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
-        { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
diff --git a/test/Feature/RollbackSpec.hs b/test/Feature/RollbackSpec.hs
deleted file mode 100644
--- a/test/Feature/RollbackSpec.hs
+++ /dev/null
@@ -1,218 +0,0 @@
-module Feature.RollbackSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude hiding (get)
-
--- two helpers functions to make sure that each test can setup and cleanup properly
-
--- creates Item to work with for PATCH and DELETE
-postItem =
-  request methodPost "/items"
-      [("Prefer", "tx=commit"), ("Prefer", "resolution=ignore-duplicates")]
-      [json|{"id":0}|]
-    `shouldRespondWith`
-      ""
-      { matchStatus  = 201 }
-
--- removes Items left over from POST, PUT, and PATCH
-deleteItems =
-  request methodDelete "/items?id=lte.0"
-      [("Prefer", "tx=commit")]
-      ""
-    `shouldRespondWith`
-      ""
-      { matchStatus  = 204 }
-
-preferDefault  = [("Prefer", "return=representation")]
-preferCommit   = [("Prefer", "return=representation"), ("Prefer", "tx=commit")]
-preferRollback = [("Prefer", "return=representation"), ("Prefer", "tx=rollback")]
-
-withoutPreferenceApplied      = []
-withPreferenceCommitApplied   = [ "Preference-Applied" <:> "tx=commit" ]
-withPreferenceRollbackApplied = [ "Preference-Applied" <:> "tx=rollback" ]
-
-shouldRespondToReads reqHeaders respHeaders = do
-  it "responds to GET" $ do
-    request methodGet "/items?id=eq.1"
-        reqHeaders
-        ""
-      `shouldRespondWith`
-        [json|[{"id":1}]|]
-        { matchHeaders = respHeaders }
-
-  it "responds to HEAD" $ do
-    request methodHead "/items?id=eq.1"
-        reqHeaders
-        ""
-      `shouldRespondWith`
-        ""
-        { matchHeaders = respHeaders }
-
-  it "responds to GET on RPC" $ do
-    request methodGet "/rpc/search?id=1"
-        reqHeaders
-        ""
-      `shouldRespondWith`
-        [json|[{"id":1}]|]
-        { matchHeaders = respHeaders }
-
-  it "responds to POST on RPC" $ do
-    request methodPost "/rpc/search"
-        reqHeaders
-        [json|{"id":1}|]
-      `shouldRespondWith`
-        [json|[{"id":1}]|]
-        { matchHeaders = respHeaders }
-
-shouldPersistMutations reqHeaders respHeaders = do
-  it "does persist post" $ do
-    request methodPost "/items"
-        reqHeaders
-        [json|{"id":0}|]
-      `shouldRespondWith`
-        [json|[{"id":0}]|]
-        { matchStatus  = 201
-        , matchHeaders = respHeaders }
-    get "/items?id=eq.0"
-      `shouldRespondWith`
-        [json|[{"id":0}]|]
-    deleteItems
-
-  it "does persist put" $ do
-    request methodPut "/items?id=eq.0"
-        reqHeaders
-        [json|{"id":0}|]
-      `shouldRespondWith`
-        [json|[{"id":0}]|]
-        { matchHeaders = respHeaders }
-    get "/items?id=eq.0"
-      `shouldRespondWith`
-        [json|[{"id":0}]|]
-    deleteItems
-
-  it "does persist patch" $ do
-    postItem
-    request methodPatch "/items?id=eq.0"
-        reqHeaders
-        [json|{"id":-1}|]
-      `shouldRespondWith`
-        [json|[{"id":-1}]|]
-        { matchHeaders = respHeaders }
-    get "/items?id=eq.0"
-      `shouldRespondWith`
-        [json|[]|]
-    get "/items?id=eq.-1"
-      `shouldRespondWith`
-        [json|[{"id":-1}]|]
-    deleteItems
-
-  it "does persist delete" $ do
-    postItem
-    request methodDelete "/items?id=eq.0"
-        reqHeaders
-        ""
-      `shouldRespondWith`
-        [json|[{"id":0}]|]
-        { matchHeaders = respHeaders }
-    get "/items?id=eq.0"
-      `shouldRespondWith`
-        [json|[]|]
-
-shouldNotPersistMutations reqHeaders respHeaders = do
-  it "does not persist post" $ do
-    request methodPost "/items"
-        reqHeaders
-        [json|{"id":0}|]
-      `shouldRespondWith`
-        [json|[{"id":0}]|]
-        { matchStatus  = 201
-        , matchHeaders = respHeaders }
-    get "/items?id=eq.0"
-      `shouldRespondWith`
-        [json|[]|]
-
-  it "does not persist put" $ do
-    request methodPut "/items?id=eq.0"
-        reqHeaders
-        [json|{"id":0}|]
-      `shouldRespondWith`
-        [json|[{"id":0}]|]
-        { matchHeaders = respHeaders }
-    get "/items?id=eq.0"
-      `shouldRespondWith`
-        [json|[]|]
-
-  it "does not persist patch" $ do
-    request methodPatch "/items?id=eq.1"
-        reqHeaders
-        [json|{"id":0}|]
-      `shouldRespondWith`
-        [json|[{"id":0}]|]
-        { matchHeaders = respHeaders }
-    get "/items?id=eq.0"
-      `shouldRespondWith`
-        [json|[]|]
-    get "items?id=eq.1"
-      `shouldRespondWith`
-        [json|[{"id":1}]|]
-
-  it "does not persist delete" $ do
-    request methodDelete "/items?id=eq.1"
-        reqHeaders
-        ""
-      `shouldRespondWith`
-        [json|[{"id":1}]|]
-        { matchHeaders = respHeaders }
-    get "/items?id=eq.1"
-      `shouldRespondWith`
-        [json|[{"id":1}]|]
-
-allowed :: SpecWith ((), Application)
-allowed = describe "tx-allow-override = true" $ do
-  describe "without Prefer tx" $ do
-    preferDefault `shouldRespondToReads` withoutPreferenceApplied
-    preferDefault `shouldNotPersistMutations` withoutPreferenceApplied
-
-  describe "Prefer tx=commit" $ do
-    preferCommit `shouldRespondToReads` withPreferenceCommitApplied
-    preferCommit `shouldPersistMutations` withPreferenceCommitApplied
-
-  describe "Prefer tx=rollback" $ do
-    preferRollback `shouldRespondToReads` withPreferenceRollbackApplied
-    preferRollback `shouldNotPersistMutations` withPreferenceRollbackApplied
-
-disallowed :: SpecWith ((), Application)
-disallowed = describe "tx-rollback-all = false, tx-allow-override = false" $ do
-  describe "without Prefer tx" $ do
-    preferDefault `shouldRespondToReads` withoutPreferenceApplied
-    preferDefault `shouldPersistMutations` withoutPreferenceApplied
-
-  describe "Prefer tx=commit" $ do
-    preferCommit `shouldRespondToReads` withoutPreferenceApplied
-    preferCommit `shouldPersistMutations` withoutPreferenceApplied
-
-  describe "Prefer tx=rollback" $ do
-    preferRollback `shouldRespondToReads` withoutPreferenceApplied
-    preferRollback `shouldPersistMutations` withoutPreferenceApplied
-
-
-forced :: SpecWith ((), Application)
-forced = describe "tx-rollback-all = true, tx-allow-override = false" $ do
-  describe "without Prefer tx" $ do
-    preferDefault `shouldRespondToReads` withoutPreferenceApplied
-    preferDefault `shouldNotPersistMutations` withoutPreferenceApplied
-
-  describe "Prefer tx=commit" $ do
-    preferCommit `shouldRespondToReads` withoutPreferenceApplied
-    preferCommit `shouldNotPersistMutations` withoutPreferenceApplied
-
-  describe "Prefer tx=rollback" $ do
-    preferRollback `shouldRespondToReads` withoutPreferenceApplied
-    preferRollback `shouldNotPersistMutations` withoutPreferenceApplied
-
diff --git a/test/Feature/RootSpec.hs b/test/Feature/RootSpec.hs
deleted file mode 100644
--- a/test/Feature/RootSpec.hs
+++ /dev/null
@@ -1,34 +0,0 @@
-module Feature.RootSpec where
-
-import Network.HTTP.Types
-import Network.Wai        (Application)
-
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude hiding (get)
-
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "root spec function" $ do
-    it "accepts application/openapi+json" $
-      request methodGet "/"
-        [("Accept","application/openapi+json")] "" `shouldRespondWith`
-        [json|{
-           "swagger": "2.0",
-           "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}
-          }|]
-        { matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] }
-
-    it "accepts application/json" $
-      request methodGet "/"
-        [("Accept", "application/json")] "" `shouldRespondWith`
-        [json| {
-            "tableName": "orders_view", "tableSchema": "test",
-            "tableDeletable": true, "tableUpdatable": true,
-            "tableInsertable": true, "tableDescription": null
-          } |]
-        { matchHeaders = [matchContentTypeJson] }
diff --git a/test/Feature/RpcPreRequestGucsSpec.hs b/test/Feature/RpcPreRequestGucsSpec.hs
deleted file mode 100644
--- a/test/Feature/RpcPreRequestGucsSpec.hs
+++ /dev/null
@@ -1,84 +0,0 @@
-module Feature.RpcPreRequestGucsSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec          hiding (pendingWith)
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude hiding (get, put)
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "GUC headers on all methods via pre-request" $ do
-    it "succeeds setting the headers on POST" $
-      post "/items"
-          [json|[{"id": 11111}]|]
-        `shouldRespondWith` ""
-          { matchStatus = 201
-          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
-          }
-
-    it "succeeds setting the headers on GET and HEAD" $ do
-      request methodGet "/items?id=eq.1"
-          [("User-Agent", "MSIE 6.0")]
-          ""
-        `shouldRespondWith`
-          [json|[{"id": 1}]|]
-          { matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"] }
-
-      request methodHead "/items?id=eq.1"
-          [("User-Agent", "MSIE 7.0")]
-          ""
-        `shouldRespondWith`
-          ""
-          { matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"] }
-
-      request methodHead "/projects"
-          [("Accept", "text/csv")]
-          ""
-        `shouldRespondWith`
-          ""
-          { matchHeaders = ["Content-Disposition" <:> "attachment; filename=projects.csv"] }
-
-    it "succeeds setting the headers on PATCH" $
-        patch "/items?id=eq.1"
-            [json|[{"id": 11111}]|]
-          `shouldRespondWith` ""
-            { matchStatus = 204
-            , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
-            }
-
-    it "succeeds setting the headers on PUT" $
-      put "/items?id=eq.1"
-          [json|[{"id": 1}]|]
-        `shouldRespondWith` ""
-          { matchStatus = 204
-          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
-          }
-
-    it "succeeds setting the headers on DELETE" $
-      delete "/items?id=eq.1"
-        `shouldRespondWith`
-          ""
-          { matchStatus = 204
-          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
-          }
-    it "can override the Content-Type header" $ do
-      request methodHead "/clients?id=eq.1"
-          []
-          ""
-        `shouldRespondWith`
-          ""
-          { matchStatus = 200
-          , matchHeaders = ["Content-Type" <:> "application/custom+json"]
-          }
-      request methodHead "/rpc/getallprojects"
-          []
-          ""
-        `shouldRespondWith`
-          ""
-          { matchStatus = 200
-          , matchHeaders = ["Content-Type" <:> "application/custom+json"]
-          }
diff --git a/test/Feature/RpcSpec.hs b/test/Feature/RpcSpec.hs
deleted file mode 100644
--- a/test/Feature/RpcSpec.hs
+++ /dev/null
@@ -1,1256 +0,0 @@
-module Feature.RpcSpec where
-
-import qualified Data.ByteString.Lazy as BL (empty, readFile)
-
-import Network.Wai      (Application)
-import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
-
-import Network.HTTP.Types
-import System.IO.Unsafe    (unsafePerformIO)
-import Test.Hspec          hiding (pendingWith)
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-import Text.Heredoc
-
-import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
-                                   pgVersion109, pgVersion110,
-                                   pgVersion112, pgVersion114,
-                                   pgVersion140)
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: PgVersion -> SpecWith ((), Application)
-spec actualPgVersion =
-  describe "remote procedure call" $ do
-    context "a proc that returns a set" $ do
-      it "returns paginated results" $ do
-        request methodPost "/rpc/getitemrange"
-                (rangeHdrs (ByteRangeFromTo 0 0))  [json| { "min": 2, "max": 4 } |]
-           `shouldRespondWith` [json| [{"id":3}] |]
-            { matchStatus = 200
-            , matchHeaders = ["Content-Range" <:> "0-0/*"]
-            }
-        request methodGet "/rpc/getitemrange?min=2&max=4"
-                (rangeHdrs (ByteRangeFromTo 0 0)) ""
-           `shouldRespondWith` [json| [{"id":3}] |]
-            { matchStatus = 200
-            , matchHeaders = ["Content-Range" <:> "0-0/*"]
-            }
-        request methodHead "/rpc/getitemrange?min=2&max=4"
-                (rangeHdrs (ByteRangeFromTo 0 0)) ""
-           `shouldRespondWith` ""
-            { matchStatus = 200
-            , matchHeaders = ["Content-Range" <:> "0-0/*"]
-            }
-
-      it "includes total count if requested" $ do
-        request methodPost "/rpc/getitemrange"
-                (rangeHdrsWithCount (ByteRangeFromTo 0 0))
-                [json| { "min": 2, "max": 4 } |]
-           `shouldRespondWith` [json| [{"id":3}] |]
-            { matchStatus = 206 -- it now knows the response is partial
-            , matchHeaders = ["Content-Range" <:> "0-0/2"]
-            }
-        request methodGet "/rpc/getitemrange?min=2&max=4"
-                (rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""
-           `shouldRespondWith` [json| [{"id":3}] |]
-            { matchStatus = 206
-            , matchHeaders = ["Content-Range" <:> "0-0/2"]
-            }
-        request methodHead "/rpc/getitemrange?min=2&max=4"
-                (rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""
-           `shouldRespondWith` ""
-            { matchStatus = 206
-            , matchHeaders = ["Content-Range" <:> "0-0/2"]
-            }
-
-      it "returns proper json" $ do
-        post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
-          [json| [ {"id": 3}, {"id":4} ] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/rpc/getitemrange?min=2&max=4" `shouldRespondWith`
-          [json| [ {"id": 3}, {"id":4} ] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "returns CSV" $ do
-        request methodPost "/rpc/getitemrange"
-                (acceptHdrs "text/csv")
-                [json| { "min": 2, "max": 4 } |]
-           `shouldRespondWith` "id\n3\n4"
-            { matchStatus = 200
-            , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
-            }
-        request methodGet "/rpc/getitemrange?min=2&max=4"
-                (acceptHdrs "text/csv") ""
-           `shouldRespondWith` "id\n3\n4"
-            { matchStatus = 200
-            , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
-            }
-        request methodHead "/rpc/getitemrange?min=2&max=4"
-                (acceptHdrs "text/csv") ""
-           `shouldRespondWith` ""
-            { matchStatus = 200
-            , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
-            }
-
-    context "unknown function" $ do
-      it "returns 404" $
-        post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404
-
-      it "should fail with 404 on unknown proc name" $
-        get "/rpc/fake" `shouldRespondWith` 404
-
-      it "should fail with 404 on unknown proc args" $ do
-        get "/rpc/sayhello" `shouldRespondWith` 404
-        get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
-
-      it "should not ignore unknown args and fail with 404" $
-        get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`
-        [json| {
-          "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
-          "message":"Could not find the test.add_them(a, b, smthelse) function in the schema cache" } |]
-        { matchStatus  = 404
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-      it "should fail with 404 when no json arg is found with prefer single object" $
-        request methodPost "/rpc/sayhello"
-          [("Prefer","params=single-object")]
-          [json|{}|]
-        `shouldRespondWith`
-          [json| {
-            "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
-            "message":"Could not find the test.sayhello function with a single json or jsonb parameter in the schema cache" } |]
-        { matchStatus  = 404
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-      it "should fail with 404 for overloaded functions with unknown args" $ do
-        get "/rpc/overloaded?wrong_arg=value" `shouldRespondWith`
-          [json| {
-            "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
-            "message":"Could not find the test.overloaded(wrong_arg) function in the schema cache" } |]
-          { matchStatus  = 404
-          , matchHeaders = [matchContentTypeJson]
-          }
-        get "/rpc/overloaded?a=1&b=2&wrong_arg=value" `shouldRespondWith`
-          [json| {
-            "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
-            "message":"Could not find the test.overloaded(a, b, wrong_arg) function in the schema cache" } |]
-          { matchStatus  = 404
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-    context "ambiguous overloaded functions with same parameters' names but different types" $ do
-      it "should fail with 300 Multiple Choices without explicit type casts" $
-        get "/rpc/overloaded_same_args?arg=value" `shouldRespondWith`
-          [json| {
-            "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved",
-            "message":"Could not choose the best candidate function between: test.overloaded_same_args(arg => integer), test.overloaded_same_args(arg => xml), test.overloaded_same_args(arg => text, num => integer)"}|]
-          { matchStatus  = 300
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-    it "works when having uppercase identifiers" $ do
-      get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith`
-        [json|{"user": "mscott", "fullName": "Michael Scott", "SSN": "401-32-XXXX"}|]
-        { matchHeaders = [matchContentTypeJson] }
-      post "/rpc/quotedFunction"
-        [json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
-        `shouldRespondWith`
-        [json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
-        { matchHeaders = [matchContentTypeJson] }
-
-    context "shaping the response returned by a proc" $ do
-      it "returns a project" $ do
-        post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`
-          [json|[{"id":1,"name":"Windows 7","client_id":1}]|]
-        get "/rpc/getproject?id=1" `shouldRespondWith`
-          [json|[{"id":1,"name":"Windows 7","client_id":1}]|]
-
-      it "can filter proc results" $ do
-        post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`
-          [json|[{"id":2},{"id":3},{"id":4}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" `shouldRespondWith`
-          [json|[{"id":2},{"id":3},{"id":4}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "can limit proc results" $ do
-        post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1" [json| {} |]
-          `shouldRespondWith` [json|[{"id":3},{"id":4}]|]
-             { matchStatus = 200
-             , matchHeaders = ["Content-Range" <:> "1-2/*"] }
-        get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1"
-          `shouldRespondWith` [json|[{"id":3},{"id":4}]|]
-             { matchStatus = 200
-             , matchHeaders = ["Content-Range" <:> "1-2/*"] }
-
-      it "select works on the first level" $ do
-        post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`
-          [json|[{"id":1,"name":"Windows 7"}]|]
-        get "/rpc/getproject?id=1&select=id,name" `shouldRespondWith`
-          [json|[{"id":1,"name":"Windows 7"}]|]
-
-    context "foreign entities embedding" $ do
-      it "can embed if related tables are in the exposed schema" $ do
-        post "/rpc/getproject?select=id,name,client:clients(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
-          [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/rpc/getproject?id=1&select=id,name,client:clients(id),tasks(id)" `shouldRespondWith`
-          [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "cannot embed if the related table is not in the exposed schema" $ do
-        post "/rpc/single_article?select=*,article_stars(*)" [json|{ "id": 1}|]
-          `shouldRespondWith` 400
-        get "/rpc/single_article?id=1&select=*,article_stars(*)"
-          `shouldRespondWith` 400
-
-      it "can embed if the related tables are in a hidden schema but exposed as views" $ do
-        post "/rpc/single_article?select=id,articleStars(userId)"
-            [json|{ "id": 2}|]
-          `shouldRespondWith`
-            [json|{"id": 2, "articleStars": [{"userId": 3}]}|]
-        get "/rpc/single_article?id=2&select=id,articleStars(userId)"
-          `shouldRespondWith`
-            [json|{"id": 2, "articleStars": [{"userId": 3}]}|]
-
-      it "can embed an M2M relationship table" $
-        get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"
-          `shouldRespondWith` [json|[
-            {"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},
-            {"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}
-          ]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "can embed an M2M relationship table that has a parent relationship table" $
-        get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"
-          `shouldRespondWith` [json|[
-            {"name":"Michael Scott","tasks":[
-              {"name":"Design IOS","project":{"name":"IOS"}},
-              {"name":"Code IOS","project":{"name":"IOS"}},
-              {"name":"Design OSX","project":{"name":"OSX"}}
-            ]},
-            {"name":"Dwight Schrute","tasks":[
-              {"name":"Design w7","project":{"name":"Windows 7"}},
-              {"name":"Design IOS","project":{"name":"IOS"}}
-            ]}
-          ]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      when (actualPgVersion >= pgVersion110) $
-        it "can embed if rpc returns domain of table type" $ do
-          post "/rpc/getproject_domain?select=id,name,client:clients(id),tasks(id)"
-              [json| { "id": 1} |]
-            `shouldRespondWith`
-              [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
-          get "/rpc/getproject_domain?id=1&select=id,name,client:clients(id),tasks(id)"
-            `shouldRespondWith`
-              [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
-
-    context "a proc that returns an empty rowset" $
-      it "returns empty json array" $ do
-        post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
-          [json| [] |]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/rpc/test_empty_rowset" `shouldRespondWith`
-          [json| [] |]
-          { matchHeaders = [matchContentTypeJson] }
-
-    context "proc return types" $ do
-      context "returns text" $ do
-        it "returns proper json" $
-          post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
-            [json|"Hello, world"|]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can handle unicode" $
-          post "/rpc/sayhello" [json| { "name": "￥" } |] `shouldRespondWith`
-            [json|"Hello, ￥"|]
-            { matchHeaders = [matchContentTypeJson] }
-
-      it "returns array" $
-        post "/rpc/ret_array" [json|{}|] `shouldRespondWith`
-          [json|[1, 2, 3]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "returns setof integers" $
-        post "/rpc/ret_setof_integers"
-            [json|{}|]
-          `shouldRespondWith`
-            [json|[1,2,3]|]
-
-      it "returns enum value" $
-        post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`
-          [json|"foo"|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "returns domain value" $
-        post "/rpc/ret_domain" [json|{ "val": "8" }|] `shouldRespondWith`
-          [json|8|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "returns range" $
-        post "/rpc/ret_range" [json|{ "low": 10, "up": 20 }|] `shouldRespondWith`
-          [json|"[10,20)"|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "returns row of scalars" $
-        post "/rpc/ret_scalars" [json|{}|] `shouldRespondWith`
-          [json|[{"a":"scalars", "b":"foo", "c":1, "d":"[10,20)"}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "returns composite type in exposed schema" $
-        post "/rpc/ret_point_2d"
-            [json|{}|]
-          `shouldRespondWith`
-            [json|{"x": 10, "y": 5}|]
-
-      it "cannot return composite type in hidden schema" $
-        post "/rpc/ret_point_3d" [json|{}|] `shouldRespondWith` 401
-
-      when (actualPgVersion >= pgVersion110) $
-        it "returns domain of composite type" $
-          post "/rpc/ret_composite_domain"
-              [json|{}|]
-            `shouldRespondWith`
-              [json|{"x": 10, "y": 5}|]
-
-      it "returns single row from table" $
-        post "/rpc/single_article?select=id"
-            [json|{"id": 2}|]
-          `shouldRespondWith`
-            [json|{"id": 2}|]
-
-      it "returns null for void" $
-        post "/rpc/ret_void"
-            [json|{}|]
-          `shouldRespondWith`
-            "null"
-            { matchHeaders = [matchContentTypeJson] }
-
-      it "returns null for an integer with null value" $
-        post "/rpc/ret_null"
-            [json|{}|]
-          `shouldRespondWith`
-            "null"
-            { matchHeaders = [matchContentTypeJson] }
-
-      context "different types when overloaded" $ do
-        it "returns composite type" $
-          post "/rpc/ret_point_overloaded"
-              [json|{"x": 1, "y": 2}|]
-            `shouldRespondWith`
-              [json|{"x": 1, "y": 2}|]
-
-        it "returns json scalar with prefer single object" $
-          request methodPost "/rpc/ret_point_overloaded" [("Prefer","params=single-object")]
-            [json|{"x": 1, "y": 2}|]
-            `shouldRespondWith`
-            [json|{"x": 1, "y": 2}|]
-            { matchHeaders = [matchContentTypeJson] }
-
-    context "proc argument types" $ do
-      -- different syntax for array needed for pg<10
-      when (actualPgVersion < pgVersion100) $
-        it "accepts a variety of arguments (Postgres < 10)" $
-          post "/rpc/varied_arguments"
-              [json| {
-                "double": 3.1,
-                "varchar": "hello",
-                "boolean": true,
-                "date": "20190101",
-                "money": 0,
-                "enum": "foo",
-                "arr": "{a,b,c}",
-                "integer": 43,
-                "json": {"some key": "some value"},
-                "jsonb": {"another key": [1, 2, "3"]}
-              } |]
-            `shouldRespondWith`
-              [json| {
-                "double": 3.1,
-                "varchar": "hello",
-                "boolean": true,
-                "date": "2019-01-01",
-                "money": "$0.00",
-                "enum": "foo",
-                "arr": ["a", "b", "c"],
-                "integer": 43,
-                "json": {"some key": "some value"},
-                "jsonb": {"another key": [1, 2, "3"]}
-              } |]
-              { matchHeaders = [matchContentTypeJson] }
-
-      when (actualPgVersion >= pgVersion100) $
-        it "accepts a variety of arguments (Postgres >= 10)" $
-          post "/rpc/varied_arguments"
-              [json| {
-                "double": 3.1,
-                "varchar": "hello",
-                "boolean": true,
-                "date": "20190101",
-                "money": 0,
-                "enum": "foo",
-                "arr": ["a", "b", "c"],
-                "integer": 43,
-                "json": {"some key": "some value"},
-                "jsonb": {"another key": [1, 2, "3"]}
-              } |]
-            `shouldRespondWith`
-              [json| {
-                "double": 3.1,
-                "varchar": "hello",
-                "boolean": true,
-                "date": "2019-01-01",
-                "money": "$0.00",
-                "enum": "foo",
-                "arr": ["a", "b", "c"],
-                "integer": 43,
-                "json": {"some key": "some value"},
-                "jsonb": {"another key": [1, 2, "3"]}
-              } |]
-              { matchHeaders = [matchContentTypeJson] }
-
-      it "accepts a variety of arguments with GET" $
-        -- without JSON / JSONB here, because passing those via query string is useless - they just become a "json string" all the time
-        get "/rpc/varied_arguments?double=3.1&varchar=hello&boolean=true&date=20190101&money=0&enum=foo&arr=%7Ba,b,c%7D&integer=43"
-          `shouldRespondWith`
-              [json| {
-                "double": 3.1,
-                "varchar": "hello",
-                "boolean": true,
-                "date": "2019-01-01",
-                "money": "$0.00",
-                "enum": "foo",
-                "arr": ["a", "b", "c"],
-                "integer": 43,
-                "json": {},
-                "jsonb": {}
-              } |]
-            { matchHeaders = [matchContentTypeJson] }
-
-      it "accepts a variety of arguments from an html form" $
-        request methodPost "/rpc/varied_arguments"
-            [("Content-Type", "application/x-www-form-urlencoded")]
-            "double=3.1&varchar=hello&boolean=true&date=20190101&money=0&enum=foo&arr=%7Ba,b,c%7D&integer=43"
-          `shouldRespondWith`
-              [json| {
-                "double": 3.1,
-                "varchar": "hello",
-                "boolean": true,
-                "date": "2019-01-01",
-                "money": "$0.00",
-                "enum": "foo",
-                "arr": ["a", "b", "c"],
-                "integer": 43,
-                "json": {},
-                "jsonb": {}
-              } |]
-            { matchHeaders = [matchContentTypeJson] }
-
-      it "parses embedded JSON arguments as JSON" $
-        post "/rpc/json_argument"
-            [json| { "arg": { "key": 3 } } |]
-          `shouldRespondWith`
-            [json|"object"|]
-            { matchHeaders = [matchContentTypeJson] }
-
-      when (actualPgVersion < pgVersion100) $
-        it "parses quoted JSON arguments as JSON (Postgres < 10)" $
-          post "/rpc/json_argument"
-              [json| { "arg": "{ \"key\": 3 }" } |]
-            `shouldRespondWith`
-              [json|"object"|]
-              { matchHeaders = [matchContentTypeJson] }
-
-      when ((actualPgVersion >= pgVersion109 && actualPgVersion < pgVersion110)
-            || actualPgVersion >= pgVersion114) $
-        it "parses quoted JSON arguments as JSON string (from Postgres 10.9, 11.4)" $
-          post "/rpc/json_argument"
-              [json| { "arg": "{ \"key\": 3 }" } |]
-            `shouldRespondWith`
-              [json|"string"|]
-              { matchHeaders = [matchContentTypeJson] }
-
-    context "improper input" $ do
-      it "rejects unknown content type even if payload is good" $ do
-        request methodPost "/rpc/sayhello"
-          (acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]
-            `shouldRespondWith` 415
-        request methodGet "/rpc/sayhello?name=world"
-          (acceptHdrs "audio/mpeg3") ""
-            `shouldRespondWith` 415
-      it "rejects malformed json payload" $ do
-        p <- request methodPost "/rpc/sayhello"
-          (acceptHdrs "application/json") "sdfsdf"
-        liftIO $ do
-          simpleStatus p `shouldBe` badRequest400
-          isErrorFormat (simpleBody p) `shouldBe` True
-      it "treats simple plpgsql raise as invalid input" $ do
-        p <- post "/rpc/problem" "{}"
-        liftIO $ do
-          simpleStatus p `shouldBe` badRequest400
-          isErrorFormat (simpleBody p) `shouldBe` True
-      it "treats plpgsql assert as internal server error" $ do
-        p <- post "/rpc/assert" "{}"
-        liftIO $ do
-          simpleStatus p `shouldBe` internalServerError500
-          isErrorFormat (simpleBody p) `shouldBe` True
-
-    context "unsupported verbs" $ do
-      it "DELETE fails" $
-        request methodDelete "/rpc/sayhello" [] ""
-          `shouldRespondWith`
-          [json|{"message":"Bad Request"}|]
-          { matchStatus  = 405
-          , matchHeaders = [matchContentTypeJson]
-          }
-      it "PATCH fails" $
-        request methodPatch "/rpc/sayhello" [] ""
-          `shouldRespondWith` 405
-      it "OPTIONS fails" $
-        -- TODO: should return info about the function
-        request methodOptions "/rpc/sayhello" [] ""
-          `shouldRespondWith` 405
-
-    it "executes the proc exactly once per request" $ do
-      -- callcounter is persistent even with rollback, because it uses a sequence
-      -- reset counter first to make test repeatable
-      request methodPost "/rpc/reset_sequence"
-          [("Prefer", "tx=commit")]
-          [json|{"name": "callcounter_count", "value": 1}|]
-        `shouldRespondWith`
-          [json|""|]
-
-      -- now the test
-      post "/rpc/callcounter"
-          [json|{}|]
-        `shouldRespondWith`
-          [json|1|]
-
-      post "/rpc/callcounter"
-          [json|{}|]
-        `shouldRespondWith`
-          [json|2|]
-
-    context "a proc that receives no parameters" $ do
-      it "interprets empty string as empty json object on a post request" $
-        post "/rpc/noparamsproc" BL.empty `shouldRespondWith`
-          [json| "Return value of no parameters procedure." |]
-          { matchHeaders = [matchContentTypeJson] }
-      it "interprets empty string as a function with no args on a get request" $
-        get "/rpc/noparamsproc" `shouldRespondWith`
-          [json| "Return value of no parameters procedure." |]
-          { matchHeaders = [matchContentTypeJson] }
-
-    it "returns proper output when having the same return col name as the proc name" $ do
-      post "/rpc/test" [json|{}|] `shouldRespondWith`
-        [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }
-      get "/rpc/test" `shouldRespondWith`
-        [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }
-
-    context "procs with OUT/INOUT params" $ do
-      it "returns an object result when there is a single OUT param" $ do
-        get "/rpc/single_out_param?num=5"
-          `shouldRespondWith`
-            [json|{"num_plus_one":6}|]
-
-        get "/rpc/single_json_out_param?a=1&b=two"
-          `shouldRespondWith`
-            [json|{"my_json": {"a": 1, "b": "two"}}|]
-
-      it "returns an object result when there is a single INOUT param" $
-        get "/rpc/single_inout_param?num=2"
-          `shouldRespondWith`
-            [json|{"num":3}|]
-
-      it "returns an object result when there are many OUT params" $
-        get "/rpc/many_out_params"
-          `shouldRespondWith`
-            [json|{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}|]
-
-      it "returns an object result when there are many INOUT params" $
-        get "/rpc/many_inout_params?num=1&str=two&b=false"
-          `shouldRespondWith`
-            [json|{"num":1,"str":"two","b":false}|]
-
-    context "procs with TABLE return" $ do
-      it "returns an object result when there is a single-column TABLE return type" $
-        get "/rpc/single_column_table_return"
-          `shouldRespondWith`
-            [json|[{"a": "A"}]|]
-
-      it "returns an object result when there is a multi-column TABLE return type" $
-        get "/rpc/multi_column_table_return"
-          `shouldRespondWith`
-            [json|[{"a": "A", "b": "B"}]|]
-
-    context "procs with VARIADIC params" $ do
-      when (actualPgVersion < pgVersion100) $
-        it "works with POST (Postgres < 10)" $
-          post "/rpc/variadic_param"
-              [json| { "v": "{hi,hello,there}" } |]
-            `shouldRespondWith`
-              [json|["hi", "hello", "there"]|]
-
-      when (actualPgVersion >= pgVersion100) $ do
-        it "works with POST (Postgres >= 10)" $
-          post "/rpc/variadic_param"
-              [json| { "v": ["hi", "hello", "there"] } |]
-            `shouldRespondWith`
-              [json|["hi", "hello", "there"]|]
-
-        context "works with GET and repeated params" $ do
-          it "n=0 (through DEFAULT)" $
-            get "/rpc/variadic_param"
-              `shouldRespondWith`
-                [json|[]|]
-
-          it "n=1" $
-            get "/rpc/variadic_param?v=hi"
-              `shouldRespondWith`
-                [json|["hi"]|]
-
-          it "n>1" $
-            get "/rpc/variadic_param?v=hi&v=there"
-              `shouldRespondWith`
-                [json|["hi", "there"]|]
-
-        context "works with POST and repeated params from html form" $ do
-          it "n=0 (through DEFAULT)" $
-            request methodPost "/rpc/variadic_param"
-                [("Content-Type", "application/x-www-form-urlencoded")]
-                ""
-              `shouldRespondWith`
-                [json|[]|]
-
-          it "n=1" $
-            request methodPost "/rpc/variadic_param"
-                [("Content-Type", "application/x-www-form-urlencoded")]
-                "v=hi"
-              `shouldRespondWith`
-                [json|["hi"]|]
-
-          it "n>1" $
-            request methodPost "/rpc/variadic_param"
-                [("Content-Type", "application/x-www-form-urlencoded")]
-                "v=hi&v=there"
-              `shouldRespondWith`
-                [json|["hi", "there"]|]
-
-    it "returns last value for repeated params without VARIADIC" $
-      get "/rpc/sayhello?name=ignored&name=world"
-        `shouldRespondWith`
-          [json|"Hello, world"|]
-
-    when (actualPgVersion >= pgVersion100) $
-      it "returns last value for repeated non-variadic params in function with other VARIADIC arguments" $
-        get "/rpc/sayhello_variadic?name=ignored&name=world&v=unused"
-          `shouldRespondWith`
-            [json|"Hello, world"|]
-
-    it "can handle procs with args that have a DEFAULT value" $ do
-      get "/rpc/many_inout_params?num=1&str=two"
-        `shouldRespondWith`
-          [json| {"num":1,"str":"two","b":true}|]
-      get "/rpc/three_defaults?b=4"
-        `shouldRespondWith`
-          [json|8|]
-
-    it "can map a RAISE error code and message to a http status" $
-      get "/rpc/raise_pt402"
-        `shouldRespondWith` [json|{ "hint": "Upgrade your plan", "details": "Quota exceeded" }|]
-        { matchStatus  = 402
-        , matchHeaders = [matchContentTypeJson]
-        }
-
-    it "defaults to status 500 if RAISE code is PT not followed by a number" $
-      get "/rpc/raise_bad_pt"
-        `shouldRespondWith`
-        [json|{"hint": null, "details": null}|]
-        { matchStatus  = 500
-        , matchHeaders = [ matchContentTypeJson ]
-        }
-
-    context "expects a single json object" $ do
-      it "does not expand posted json into parameters" $
-        request methodPost "/rpc/singlejsonparam"
-          [("prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
-          [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "accepts parameters from an html form" $
-        request methodPost "/rpc/singlejsonparam"
-          [("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]
-          ("integer=7&double=2.71828&varchar=forms+are+fun&" <>
-           "boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
-          [json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
-                 , "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
-                 { matchHeaders = [matchContentTypeJson] }
-
-      it "works with GET" $
-        request methodGet "/rpc/singlejsonparam?p1=1&p2=text" [("Prefer","params=single-object")] ""
-          `shouldRespondWith` [json|{ "p1": "1", "p2": "text"}|]
-          { matchHeaders = [matchContentTypeJson] }
-
-    context "should work with an overloaded function" $ do
-      it "overloaded()" $
-        get "/rpc/overloaded"
-          `shouldRespondWith`
-            [json|[1,2,3]|]
-
-      it "overloaded(json) single-object" $
-        request methodPost "/rpc/overloaded"
-            [("Prefer","params=single-object")]
-            [json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
-          `shouldRespondWith`
-            [json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
-
-      it "overloaded(int, int)" $
-        get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]
-
-      it "overloaded(text, text, text)" $
-        get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [json|"123"|]
-
-      it "overloaded_html_form()" $
-        request methodPost "/rpc/overloaded_html_form"
-            [("Content-Type", "application/x-www-form-urlencoded")]
-            ""
-          `shouldRespondWith`
-            [json|[1,2,3]|]
-
-      it "overloaded_html_form(json) single-object" $
-        request methodPost "/rpc/overloaded_html_form"
-            [("Content-Type", "application/x-www-form-urlencoded"), ("Prefer","params=single-object")]
-            "a=1&b=2&c=3"
-          `shouldRespondWith`
-            [json|{"a": "1", "b": "2", "c": "3"}|]
-
-      it "overloaded_html_form(int, int)" $
-        request methodPost "/rpc/overloaded_html_form"
-            [("Content-Type", "application/x-www-form-urlencoded")]
-            "a=1&b=2"
-          `shouldRespondWith`
-            [str|3|]
-
-      it "overloaded_html_form(text, text, text)" $
-        request methodPost "/rpc/overloaded_html_form"
-            [("Content-Type", "application/x-www-form-urlencoded")]
-            "a=1&b=2&c=3"
-          `shouldRespondWith`
-            [json|"123"|]
-
-      -- https://github.com/PostgREST/postgrest/issues/1672
-      context "embedding overloaded functions with the same signature except for the last param with a default value" $ do
-        it "overloaded_default(text default)" $ do
-          request methodPost "/rpc/overloaded_default?select=id,name,users(name)"
-              [("Content-Type", "application/json")]
-              [json|{}|]
-            `shouldRespondWith`
-              [json|[{"id": 2, "name": "Code w7", "users": [{"name": "Angela Martin"}]}] |]
-
-        it "overloaded_default(int)" $
-          request methodPost "/rpc/overloaded_default"
-              [("Content-Type", "application/json")]
-              [json|{"must_param":1}|]
-            `shouldRespondWith`
-              [json|{"val":1}|]
-
-        it "overloaded_default(int, text default)" $ do
-          request methodPost "/rpc/overloaded_default?select=id,name,users(name)"
-              [("Content-Type", "application/json")]
-              [json|{"a":4}|]
-            `shouldRespondWith`
-              [json|[{"id": 5, "name": "Design IOS", "users": [{"name": "Michael Scott"}, {"name": "Dwight Schrute"}]}] |]
-
-        it "overloaded_default(int, int)" $
-          request methodPost "/rpc/overloaded_default"
-              [("Content-Type", "application/json")]
-              [json|{"a":2,"must_param":4}|]
-            `shouldRespondWith`
-              [json|{"a":2,"val":4}|]
-
-    context "only for POST rpc" $ do
-      it "gives a parse filter error if GET style proc args are specified" $
-        post "/rpc/sayhello?name=John" [json|{name: "John"}|] `shouldRespondWith` 400
-
-      it "ignores json keys not included in ?columns" $
-        post "/rpc/sayhello?columns=name"
-          [json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|]
-          `shouldRespondWith`
-          [json|"Hello, John"|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "only takes the first object in case of array of objects payload" $
-        post "/rpc/add_them"
-          [json|[
-            {"a": 1, "b": 2},
-            {"a": 4, "b": 6},
-            {"a": 100, "b": 200} ]|]
-          `shouldRespondWith` "3"
-          { matchHeaders = [matchContentTypeJson] }
-
-    context "bulk RPC with params=multiple-objects" $ do
-      it "works with a scalar function an returns a json array" $
-        request methodPost "/rpc/add_them" [("Prefer", "params=multiple-objects")]
-          [json|[
-            {"a": 1, "b": 2},
-            {"a": 4, "b": 6},
-            {"a": 100, "b": 200} ]|]
-          `shouldRespondWith`
-          [json|
-            [3, 10, 300]
-          |] { matchHeaders = [matchContentTypeJson] }
-
-      it "works with a scalar function an returns a json array when posting CSV" $
-        request methodPost "/rpc/add_them" [("Content-Type", "text/csv"), ("Prefer", "params=multiple-objects")]
-          "a,b\n1,2\n4,6\n100,200"
-          `shouldRespondWith`
-          [json|
-            [3, 10, 300]
-          |]
-          { matchStatus  = 200
-          , matchHeaders = [matchContentTypeJson]
-          }
-
-      it "works with a non-scalar result" $
-        request methodPost "/rpc/get_projects_below?select=id,name" [("Prefer", "params=multiple-objects")]
-          [json|[
-            {"id": 1},
-            {"id": 5} ]|]
-          `shouldRespondWith`
-          [json|
-            [{"id":1,"name":"Windows 7"},
-             {"id":2,"name":"Windows 10"},
-             {"id":3,"name":"IOS"},
-             {"id":4,"name":"OSX"}]
-          |] { matchHeaders = [matchContentTypeJson] }
-
-    context "HTTP request env vars" $ do
-      it "custom header is set" $
-        request methodPost "/rpc/get_guc_value"
-                  [("Custom-Header", "test")]
-            (
-            if actualPgVersion >= pgVersion140 then
-              [json| { "prefix": "request.headers", "name": "custom-header" } |]
-            else
-              [json| { "name": "request.header.custom-header" } |]
-            )
-            `shouldRespondWith`
-            [json|"test"|]
-            { matchStatus  = 200
-            , matchHeaders = [ matchContentTypeJson ]
-            }
-      it "standard header is set" $
-        request methodPost "/rpc/get_guc_value"
-                  [("Origin", "http://example.com")]
-            (
-            if actualPgVersion >= pgVersion140 then
-              [json| { "prefix": "request.headers", "name": "origin" } |]
-            else
-              [json| { "name": "request.header.origin" } |]
-            )
-            `shouldRespondWith`
-            [json|"http://example.com"|]
-            { matchStatus  = 200
-            , matchHeaders = [ matchContentTypeJson ]
-            }
-      it "current role is available as GUC claim" $
-        request methodPost "/rpc/get_guc_value" []
-            (
-            if actualPgVersion >= pgVersion140 then
-              [json| { "prefix": "request.jwt.claims", "name": "role" } |]
-            else
-              [json| { "name": "request.jwt.claim.role" } |]
-            )
-            `shouldRespondWith`
-            [json|"postgrest_test_anonymous"|]
-            { matchStatus  = 200
-            , matchHeaders = [ matchContentTypeJson ]
-            }
-      it "single cookie ends up as claims" $
-        request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
-          (
-          if actualPgVersion >= pgVersion140 then
-            [json| {"prefix": "request.cookies", "name":"acookie"} |]
-          else
-            [json| {"name":"request.cookie.acookie"} |]
-          )
-            `shouldRespondWith`
-            [json|"cookievalue"|]
-            { matchStatus = 200
-            , matchHeaders = []
-            }
-      it "multiple cookies ends up as claims" $
-        request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
-          (
-          if actualPgVersion >= pgVersion140 then
-            [json| {"prefix": "request.cookies", "name":"secondcookie"} |]
-          else
-            [json| {"name":"request.cookie.secondcookie"} |]
-          )
-            `shouldRespondWith`
-            [json|"anothervalue"|]
-            { matchStatus = 200
-            , matchHeaders = []
-            }
-      it "app settings available" $
-        request methodPost "/rpc/get_guc_value" []
-          [json| { "name": "app.settings.app_host" } |]
-            `shouldRespondWith`
-            [json|"localhost"|]
-            { matchStatus  = 200
-            , matchHeaders = [ matchContentTypeJson ]
-            }
-      it "gets the Authorization value" $
-        request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
-          (
-          if actualPgVersion >= pgVersion140 then
-            [json| {"prefix": "request.headers", "name":"authorization"} |]
-          else
-            [json| {"name":"request.header.authorization"} |]
-          )
-            `shouldRespondWith`
-            [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
-            { matchStatus = 200
-            , matchHeaders = []
-            }
-      it "gets the http method" $
-        request methodPost "/rpc/get_guc_value" []
-          [json| {"name":"request.method"} |]
-            `shouldRespondWith`
-            [json|"POST"|]
-            { matchStatus = 200
-            , matchHeaders = []
-            }
-      it "gets the http path" $
-        request methodPost "/rpc/get_guc_value" []
-          [json| {"name":"request.path"} |]
-            `shouldRespondWith`
-            [json|"/rpc/get_guc_value"|]
-            { matchStatus = 200
-            , matchHeaders = []
-            }
-
-    context "binary output" $ do
-      context "Proc that returns scalar" $ do
-        it "can query without selecting column" $
-          request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") ""
-            `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
-            { matchStatus = 200
-            , matchHeaders = ["Content-Type" <:> "application/octet-stream"]
-            }
-
-        it "can get raw output with Accept: text/plain" $
-          request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
-            `shouldRespondWith` "Welcome to PostgREST"
-            { matchStatus = 200
-            , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
-            }
-
-      context "Proc that returns set of scalars" $
-        it "can query without selecting column" $
-          request methodGet "/rpc/welcome_twice"
-              (acceptHdrs "text/plain")
-              ""
-            `shouldRespondWith`
-              "Welcome to PostgRESTWelcome to PostgREST"
-              { matchStatus = 200
-              , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
-              }
-
-      context "Proc that returns rows" $ do
-        it "can query if a single column is selected" $
-          request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (acceptHdrs "application/octet-stream") ""
-            `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
-            { matchStatus = 200
-            , matchHeaders = ["Content-Type" <:> "application/octet-stream"]
-            }
-
-        it "fails if a single column is not selected" $
-          request methodPost "/rpc/ret_rows_with_base64_bin"
-              (acceptHdrs "application/octet-stream") ""
-            `shouldRespondWith`
-              [json| {"message":"application/octet-stream requested but more than one column was selected"} |]
-              { matchStatus = 406 }
-
-    context "only for GET rpc" $ do
-      it "should fail on mutating procs" $ do
-        get "/rpc/callcounter" `shouldRespondWith` 405
-        get "/rpc/setprojects?id_l=1&id_h=5&name=FreeBSD" `shouldRespondWith` 405
-
-      it "should filter a proc that has arg name = filter name" $
-        get "/rpc/get_projects_below?id=5&id=gt.2&select=id" `shouldRespondWith`
-          [json|[{ "id": 3 }, { "id": 4 }]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "should work with filters that have the not operator" $ do
-        get "/rpc/get_projects_below?id=5&id=not.gt.2&select=id" `shouldRespondWith`
-          [json|[{ "id": 1 }, { "id": 2 }]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/rpc/get_projects_below?id=5&id=not.in.(1,3)&select=id" `shouldRespondWith`
-          [json|[{ "id": 2 }, { "id": 4 }]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-      it "should work with filters that use the plain with language fts operator" $ do
-        get "/rpc/get_tsearch?text_search_vector=fts(english).impossible" `shouldRespondWith`
-          [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/rpc/get_tsearch?text_search_vector=plfts.impossible" `shouldRespondWith`
-          [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`
-          [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
-          { matchHeaders = [matchContentTypeJson] }
-        when (actualPgVersion >= pgVersion112) $
-            get "/rpc/get_tsearch?text_search_vector=wfts.impossible" `shouldRespondWith`
-                [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
-                { matchHeaders = [matchContentTypeJson] }
-
-      it "should work with the phraseto_tsquery function" $
-        get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith`
-          [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
-          { matchHeaders = [matchContentTypeJson] }
-
-    it "should work with an argument of custom type in public schema" $
-        get "/rpc/test_arg?my_arg=something" `shouldRespondWith`
-          [json|"foobar"|]
-          { matchHeaders = [matchContentTypeJson] }
-
-    context "GUC headers on function calls" $ do
-      it "succeeds setting the headers" $ do
-        get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"
-          `shouldRespondWith` [json|[{"id": 2}]|]
-          {matchHeaders = [
-              matchContentTypeJson,
-              "X-Test"   <:> "key1=val1; someValue; key2=val2",
-              "X-Test-2" <:> "key1=val1"]}
-        get "/rpc/get_int_and_guc_headers?num=1"
-          `shouldRespondWith` [json|1|]
-          {matchHeaders = [
-              matchContentTypeJson,
-              "X-Test"   <:> "key1=val1; someValue; key2=val2",
-              "X-Test-2" <:> "key1=val1"]}
-        post "/rpc/get_int_and_guc_headers" [json|{"num": 1}|]
-          `shouldRespondWith` [json|1|]
-          {matchHeaders = [
-              matchContentTypeJson,
-              "X-Test"   <:> "key1=val1; someValue; key2=val2",
-              "X-Test-2" <:> "key1=val1"]}
-
-      it "fails when setting headers with wrong json structure" $ do
-        get "/rpc/bad_guc_headers_1"
-          `shouldRespondWith`
-          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
-          { matchStatus  = 500
-          , matchHeaders = [ matchContentTypeJson ]
-          }
-        get "/rpc/bad_guc_headers_2"
-          `shouldRespondWith`
-          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
-          { matchStatus  = 500
-          , matchHeaders = [ matchContentTypeJson ]
-          }
-        get "/rpc/bad_guc_headers_3"
-          `shouldRespondWith`
-          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
-          { matchStatus  = 500
-          , matchHeaders = [ matchContentTypeJson ]
-          }
-        post "/rpc/bad_guc_headers_1" [json|{}|]
-          `shouldRespondWith`
-          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
-          { matchStatus  = 500
-          , matchHeaders = [ matchContentTypeJson ]
-          }
-
-      it "can set the same http header twice" $
-        get "/rpc/set_cookie_twice"
-          `shouldRespondWith`
-            "null"
-            { matchHeaders = [ matchContentTypeJson
-                             , "Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/"
-                             , "Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly" ]}
-
-      it "can override the Location header on a trigger" $
-        post "/stuff"
-            [json|[{"id": 2, "name": "stuff 2"}]|]
-          `shouldRespondWith`
-            ""
-            { matchStatus = 201
-            , matchHeaders = ["Location" <:> "/stuff?id=eq.2&overriden=true"]
-            }
-
-      -- On https://github.com/PostgREST/postgrest/issues/1427#issuecomment-595907535
-      -- it was reported that blank headers ` : ` where added and that cause proxies to fail the requests.
-      -- These tests are to ensure no blank headers are added.
-      context "Blank headers bug" $ do
-        it "shouldn't add blank headers on POST" $ do
-          r <- request methodPost "/loc_test" [] [json|{"id": "1", "c": "c1"}|]
-          liftIO $ do
-            let respHeaders = simpleHeaders r
-            respHeaders `shouldSatisfy` noBlankHeader
-
-        it "shouldn't add blank headers on PATCH" $ do
-          r <- request methodPatch "/loc_test?id=eq.1" [] [json|{"c": "c2"}|]
-          liftIO $ do
-            let respHeaders = simpleHeaders r
-            respHeaders `shouldSatisfy` noBlankHeader
-
-        it "shouldn't add blank headers on GET" $ do
-          r <- request methodGet "/loc_test" [] ""
-          liftIO $ do
-            let respHeaders = simpleHeaders r
-            respHeaders `shouldSatisfy` noBlankHeader
-
-        it "shouldn't add blank headers on DELETE" $ do
-          r <- request methodDelete "/loc_test?id=eq.1" [] ""
-          liftIO $ do
-            let respHeaders = simpleHeaders r
-            respHeaders `shouldSatisfy` noBlankHeader
-
-      context "GUC status override" $ do
-        it "can override the status on RPC" $
-          get "/rpc/send_body_status_403"
-            `shouldRespondWith`
-            [json|{"message" : "invalid user or password"}|]
-            { matchStatus  = 403
-            , matchHeaders = [ matchContentTypeJson ]
-            }
-
-        it "can override the status through trigger" $
-          patch "/stuff?id=eq.1"
-              [json|[{"name": "updated stuff 1"}]|]
-            `shouldRespondWith`
-              205
-
-        it "fails when setting invalid status guc" $
-          get "/rpc/send_bad_status"
-            `shouldRespondWith`
-            [json|{"message":"response.status guc must be a valid status code"}|]
-            { matchStatus  = 500
-            , matchHeaders = [ matchContentTypeJson ]
-            }
-
-      context "single unnamed param" $ do
-        it "can insert json directly" $
-          post "/rpc/unnamed_json_param"
-              [json|{"A": 1, "B": 2, "C": 3}|]
-            `shouldRespondWith`
-              [json|{"A": 1, "B": 2, "C": 3}|]
-
-        it "can insert text directly" $
-          request methodPost "/rpc/unnamed_text_param"
-            [("Content-Type", "text/plain"), ("Accept", "text/plain")]
-            [str|unnamed text arg|]
-            `shouldRespondWith`
-            [str|unnamed text arg|]
-
-        it "can insert bytea directly" $ do
-          let file = unsafePerformIO $ BL.readFile "test/C.png"
-          r <- request methodPost "/rpc/unnamed_bytea_param"
-            [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
-            file
-          liftIO $ do
-            let respBody = simpleBody r
-            respBody `shouldBe` file
-
-        it "will err when no function with single unnamed json parameter exists and application/json is specified" $
-          request methodPost "/rpc/unnamed_int_param" [("Content-Type", "application/json")]
-              [json|{"x": 1, "y": 2}|]
-            `shouldRespondWith`
-              [json|{
-                "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
-                "message": "Could not find the test.unnamed_int_param(x, y) function or the test.unnamed_int_param function with a single unnamed json or jsonb parameter in the schema cache"
-              }|]
-              { matchStatus  = 404
-              , matchHeaders = [ matchContentTypeJson ]
-              }
-
-        it "will err when no function with single unnamed text parameter exists and text/plain is specified" $
-          request methodPost "/rpc/unnamed_int_param"
-              [("Content-Type", "text/plain")]
-              [str|a simple text|]
-            `shouldRespondWith`
-              [json|{
-                "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
-                "message": "Could not find the test.unnamed_int_param function with a single unnamed text parameter in the schema cache"
-              }|]
-              { matchStatus  = 404
-              , matchHeaders = [ matchContentTypeJson ]
-              }
-
-        it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $
-          let file = unsafePerformIO $ BL.readFile "test/C.png" in
-          request methodPost "/rpc/unnamed_int_param"
-              [("Content-Type", "application/octet-stream")]
-              file
-          `shouldRespondWith`
-            [json|{
-              "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
-              "message": "Could not find the test.unnamed_int_param function with a single unnamed bytea parameter in the schema cache"
-            }|]
-            { matchStatus  = 404
-            , matchHeaders = [ matchContentTypeJson ]
-            }
-
-        it "should be able to resolve when a single unnamed json parameter exists and other overloaded functions are found" $ do
-          request methodPost "/rpc/overloaded_unnamed_param" [("Content-Type", "application/json")]
-              [json|{}|]
-            `shouldRespondWith`
-              [json| 1 |]
-              { matchStatus  = 200
-              , matchHeaders = [matchContentTypeJson]
-              }
-          request methodPost "/rpc/overloaded_unnamed_param" [("Content-Type", "application/json")]
-              [json|{"x": 1, "y": 2}|]
-            `shouldRespondWith`
-              [json| 3 |]
-              { matchStatus  = 200
-              , matchHeaders = [matchContentTypeJson]
-              }
-
-        it "should be able to fallback to the single unnamed parameter function when other overloaded functions are not found" $ do
-          request methodPost "/rpc/overloaded_unnamed_param"
-              [("Content-Type", "application/json")]
-              [json|{"A": 1, "B": 2, "C": 3}|]
-            `shouldRespondWith`
-              [json|{"A": 1, "B": 2, "C": 3}|]
-          request methodPost "/rpc/overloaded_unnamed_param"
-              [("Content-Type", "text/plain"), ("Accept", "text/plain")]
-              [str|unnamed text arg|]
-            `shouldRespondWith`
-              [str|unnamed text arg|]
-          let file = unsafePerformIO $ BL.readFile "test/C.png"
-          r <- request methodPost "/rpc/overloaded_unnamed_param"
-            [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
-            file
-          liftIO $ do
-            let respBody = simpleBody r
-            respBody `shouldBe` file
-
-        it "should fail to fallback to any single unnamed parameter function when using an unsupported Content-Type header" $ do
-          request methodPost "/rpc/overloaded_unnamed_param"
-              [("Content-Type", "text/csv")]
-              "a,b\n1,2\n4,6\n100,200"
-            `shouldRespondWith`
-              [json| {
-                "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
-                "message":"Could not find the test.overloaded_unnamed_param(a, b) function in the schema cache"}|]
-              { matchStatus  = 404
-              , matchHeaders = [matchContentTypeJson]
-              }
-
-        it "should fail with multiple choices when two fallback functions with single unnamed json and jsonb parameters exist" $ do
-          request methodPost "/rpc/overloaded_unnamed_json_jsonb_param" [("Content-Type", "application/json")]
-              [json|{"A": 1, "B": 2, "C": 3}|]
-            `shouldRespondWith`
-              [json| {
-                "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved",
-                "message":"Could not choose the best candidate function between: test.overloaded_unnamed_json_jsonb_param( => json), test.overloaded_unnamed_json_jsonb_param( => jsonb)"}|]
-              { matchStatus  = 300
-              , matchHeaders = [matchContentTypeJson]
-              }
diff --git a/test/Feature/SingularSpec.hs b/test/Feature/SingularSpec.hs
deleted file mode 100644
--- a/test/Feature/SingularSpec.hs
+++ /dev/null
@@ -1,324 +0,0 @@
-module Feature.SingularSpec where
-
-import Network.Wai      (Application)
-import Network.Wai.Test (SResponse (..))
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude  hiding (get)
-import SpecHelper
-
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "Requesting singular json object" $ do
-    let singular = ("Accept", "application/vnd.pgrst.object+json")
-
-    context "with GET request" $ do
-      it "fails for zero rows" $
-        request methodGet  "/items?id=gt.0&id=lt.0" [singular] ""
-          `shouldRespondWith` 406
-
-      it "will select an existing object" $ do
-        request methodGet "/items?id=eq.5" [singular] ""
-          `shouldRespondWith`
-            [json|{"id":5}|]
-            { matchHeaders = [matchContentTypeSingular] }
-        -- also test without the +json suffix
-        request methodGet "/items?id=eq.5"
-            [("Accept", "application/vnd.pgrst.object")] ""
-          `shouldRespondWith`
-            [json|{"id":5}|]
-            { matchHeaders = [matchContentTypeSingular] }
-
-      it "can combine multiple prefer values" $
-        request methodGet "/items?id=eq.5" [singular, ("Prefer","count=none")] ""
-          `shouldRespondWith`
-            [json|{"id":5}|]
-            { matchHeaders = [matchContentTypeSingular] }
-
-      it "can shape plurality singular object routes" $
-        request methodGet "/projects_view?id=eq.1&select=id,name,clients(*),tasks(id,name)" [singular] ""
-          `shouldRespondWith`
-            [json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]
-            { matchHeaders = [matchContentTypeSingular] }
-
-    context "when updating rows" $ do
-      it "works for one row with return=rep" $ do
-        request methodPatch "/addresses?id=eq.1"
-            [("Prefer", "return=representation"), singular]
-            [json| { address: "B Street" } |]
-          `shouldRespondWith`
-            [json|{"id":1,"address":"B Street"}|]
-            { matchHeaders = [matchContentTypeSingular] }
-
-      it "works for one row with return=minimal" $
-        request methodPatch "/addresses?id=eq.1"
-            [("Prefer", "return=minimal"), singular]
-            [json| { address: "C Street" } |]
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 204 }
-
-      it "raises an error for multiple rows" $ do
-        request methodPatch "/addresses"
-            [("Prefer", "tx=commit"), singular]
-            [json| { address: "zzz" } |]
-          `shouldRespondWith`
-            [json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-            { matchStatus  = 406
-            , matchHeaders = [ matchContentTypeSingular
-                             , "Preference-Applied" <:> "tx=commit" ]
-            }
-
-        -- the rows should not be updated, either
-        get "/addresses?id=eq.1"
-          `shouldRespondWith`
-            [json|[{"id":1,"address":"address 1"}]|]
-
-      it "raises an error for multiple rows with return=rep" $ do
-        request methodPatch "/addresses"
-            [("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]
-            [json| { address: "zzz" } |]
-          `shouldRespondWith`
-            [json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-            { matchStatus  = 406
-            , matchHeaders = [ matchContentTypeSingular
-                             , "Preference-Applied" <:> "tx=commit" ]
-            }
-
-        -- the rows should not be updated, either
-        get "/addresses?id=eq.1"
-          `shouldRespondWith`
-            [json|[{"id":1,"address":"address 1"}]|]
-
-      it "raises an error for zero rows" $
-        request methodPatch "/items?id=gt.0&id=lt.0"
-                [singular] [json|{"id":1}|]
-          `shouldRespondWith`
-                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-                  { matchStatus  = 406
-                  , matchHeaders = [matchContentTypeSingular]
-                  }
-
-      it "raises an error for zero rows with return=rep" $
-        request methodPatch "/items?id=gt.0&id=lt.0"
-                [("Prefer", "return=representation"), singular] [json|{"id":1}|]
-          `shouldRespondWith`
-                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-                  { matchStatus  = 406
-                  , matchHeaders = [matchContentTypeSingular]
-                  }
-
-    context "when creating rows" $ do
-      it "works for one row with return=rep" $ do
-        request methodPost "/addresses"
-            [("Prefer", "return=representation"), singular]
-            [json| [ { id: 102, address: "xxx" } ] |]
-          `shouldRespondWith`
-            [json|{"id":102,"address":"xxx"}|]
-            { matchStatus  = 201
-            , matchHeaders = [matchContentTypeSingular]
-            }
-
-      it "works for one row with return=minimal" $ do
-        request methodPost "/addresses"
-            [("Prefer", "return=minimal"), singular]
-            [json| [ { id: 103, address: "xxx" } ] |]
-          `shouldRespondWith` ""
-            { matchStatus  = 201
-            , matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-      it "raises an error when attempting to create multiple entities" $ do
-        request methodPost "/addresses"
-            [("Prefer", "tx=commit"), singular]
-            [json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
-          `shouldRespondWith`
-            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-            { matchStatus  = 406
-            , matchHeaders = [ matchContentTypeSingular
-                             , "Preference-Applied" <:> "tx=commit" ]
-            }
-
-        -- the rows should not exist, either
-        get "/addresses?id=eq.200"
-          `shouldRespondWith`
-            "[]"
-
-      it "raises an error when attempting to create multiple entities with return=rep" $ do
-        request methodPost "/addresses"
-            [("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]
-            [json| [ { id: 202, address: "xxx" }, { id: 203, address: "yyy" } ] |]
-          `shouldRespondWith`
-            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-            { matchStatus  = 406
-            , matchHeaders = [ matchContentTypeSingular
-                             , "Preference-Applied" <:> "tx=commit" ]
-            }
-
-        -- the rows should not exist, either
-        get "/addresses?id=eq.202"
-          `shouldRespondWith`
-            "[]"
-
-      it "raises an error regardless of return=minimal" $ do
-        request methodPost "/addresses"
-            [("Prefer", "tx=commit"), ("Prefer", "return=minimal"), singular]
-            [json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]
-          `shouldRespondWith`
-            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-            { matchStatus  = 406
-            , matchHeaders = [ matchContentTypeSingular
-                             , "Preference-Applied" <:> "tx=commit" ]
-            }
-
-        -- the rows should not exist, either
-        get "/addresses?id=eq.204"
-          `shouldRespondWith`
-            "[]"
-
-      it "raises an error when creating zero entities" $
-        request methodPost "/addresses"
-                [singular]
-                [json| [ ] |]
-          `shouldRespondWith`
-                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-                  { matchStatus  = 406
-                  , matchHeaders = [matchContentTypeSingular]
-                  }
-
-      it "raises an error when creating zero entities with return=rep" $
-        request methodPost "/addresses"
-                [("Prefer", "return=representation"), singular]
-                [json| [ ] |]
-          `shouldRespondWith`
-                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-                  { matchStatus  = 406
-                  , matchHeaders = [matchContentTypeSingular]
-                  }
-
-    context "when deleting rows" $ do
-      it "works for one row with return=rep" $ do
-        p <- request methodDelete
-          "/items?id=eq.11"
-          [("Prefer", "return=representation"), singular] ""
-        liftIO $ simpleBody p `shouldBe` [json|{"id":11}|]
-
-      it "works for one row with return=minimal" $ do
-        p <- request methodDelete
-          "/items?id=eq.12"
-          [("Prefer", "return=minimal"), singular] ""
-        liftIO $ simpleBody p `shouldBe` ""
-
-      it "raises an error when attempting to delete multiple entities" $ do
-        request methodDelete "/items?id=gt.0&id=lt.6"
-            [("Prefer", "tx=commit"), singular]
-            ""
-          `shouldRespondWith`
-            [json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-            { matchStatus  = 406
-            , matchHeaders = [ matchContentTypeSingular
-                             , "Preference-Applied" <:> "tx=commit" ]
-            }
-
-        -- the rows should still exist
-        get "/items?id=gt.0&id=lt.6&order=id"
-          `shouldRespondWith`
-            [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |]
-            { matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "0-4/*"]
-            }
-
-      it "raises an error when attempting to delete multiple entities with return=rep" $ do
-        request methodDelete "/items?id=gt.5&id=lt.11"
-            [("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular] ""
-          `shouldRespondWith`
-            [json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-            { matchStatus  = 406
-            , matchHeaders = [ matchContentTypeSingular
-                             , "Preference-Applied" <:> "tx=commit" ]
-            }
-
-        -- the rows should still exist
-        get "/items?id=gt.5&id=lt.11"
-          `shouldRespondWith` [json| [{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
-            { matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "0-4/*"]
-            }
-
-      it "raises an error when deleting zero entities" $
-        request methodDelete "/items?id=lt.0"
-                [singular] ""
-          `shouldRespondWith`
-                [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-                { matchStatus  = 406
-                , matchHeaders = [matchContentTypeSingular]
-                }
-
-      it "raises an error when deleting zero entities with return=rep" $
-        request methodDelete "/items?id=lt.0"
-                [("Prefer", "return=representation"), singular] ""
-          `shouldRespondWith`
-                [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-                { matchStatus  = 406
-                , matchHeaders = [matchContentTypeSingular]
-                }
-
-    context "when calling a stored proc" $ do
-      it "fails for zero rows" $
-        request methodPost "/rpc/getproject"
-                [singular] [json|{ "id": 9999999}|]
-          `shouldRespondWith`
-                [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-                { matchStatus  = 406
-                , matchHeaders = [matchContentTypeSingular]
-                }
-
-      -- this one may be controversial, should vnd.pgrst.object include
-      -- the likes of 2 and "hello?"
-      it "succeeds for scalar result" $
-        request methodPost "/rpc/sayhello"
-          [singular] [json|{ "name": "world"}|]
-          `shouldRespondWith` 200
-
-      it "returns a single object for json proc" $
-        request methodPost "/rpc/getproject"
-            [singular] [json|{ "id": 1}|]
-          `shouldRespondWith`
-            [json|{"id":1,"name":"Windows 7","client_id":1}|]
-            { matchHeaders = [matchContentTypeSingular] }
-
-      it "fails for multiple rows" $
-        request methodPost "/rpc/getallprojects"
-                [singular] "{}"
-          `shouldRespondWith`
-                [json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-                { matchStatus  = 406
-                , matchHeaders = [matchContentTypeSingular]
-                }
-
-      it "fails for multiple rows with rolled back changes" $ do
-        post "/rpc/getproject?select=id,name"
-            [json| {"id": 1} |]
-          `shouldRespondWith`
-            [json|[{"id":1,"name":"Windows 7"}]|]
-
-        request methodPost "/rpc/setprojects"
-            [("Prefer", "tx=commit"), singular]
-            [json| {"id_l": 1, "id_h": 2, "name": "changed"} |]
-          `shouldRespondWith`
-            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
-            { matchStatus  = 406
-            , matchHeaders = [ matchContentTypeSingular
-                             , "Preference-Applied" <:> "tx=commit" ]
-            }
-
-        -- should rollback function
-        post "/rpc/getproject?select=id,name"
-            [json| {"id": 1} |]
-          `shouldRespondWith`
-            [json|[{"id":1,"name":"Windows 7"}]|]
diff --git a/test/Feature/UnicodeSpec.hs b/test/Feature/UnicodeSpec.hs
deleted file mode 100644
--- a/test/Feature/UnicodeSpec.hs
+++ /dev/null
@@ -1,34 +0,0 @@
-module Feature.UnicodeSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude hiding (get)
-
-spec :: SpecWith ((), Application)
-spec =
-  describe "Reading and writing to unicode schema and table names" $
-    it "Can read and write values" $ do
-      get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
-        `shouldRespondWith` "[]"
-
-      request methodPost "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
-          [("Prefer", "tx=commit"), ("Prefer", "return=representation")]
-          [json| { "هویت": 1 } |]
-        `shouldRespondWith`
-          [json| [{ "هویت": 1 }] |]
-          { matchStatus = 201 }
-
-      get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
-        `shouldRespondWith`
-          [json| [{ "هویت": 1 }] |]
-
-      request methodDelete "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
-          [("Prefer", "tx=commit")]
-          ""
-        `shouldRespondWith`
-          204
diff --git a/test/Feature/UpdateSpec.hs b/test/Feature/UpdateSpec.hs
deleted file mode 100644
--- a/test/Feature/UpdateSpec.hs
+++ /dev/null
@@ -1,349 +0,0 @@
-module Feature.UpdateSpec where
-
-import Network.Wai (Application)
-import Test.Hspec  hiding (pendingWith)
-
-import Network.HTTP.Types
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import Protolude  hiding (get)
-import SpecHelper
-
-spec :: SpecWith ((), Application)
-spec = do
-  describe "Patching record" $ do
-    context "to unknown uri" $
-      it "indicates no table found by returning 404" $
-        request methodPatch "/fake" []
-          [json| { "real": false } |]
-            `shouldRespondWith` 404
-
-    context "on an empty table" $
-      it "indicates no records found to update by returning 404" $
-        request methodPatch "/empty_table" []
-          [json| { "extra":20 } |]
-          `shouldRespondWith` ""
-          { matchStatus  = 404,
-            matchHeaders = []
-          }
-
-    context "with invalid json payload" $
-      it "fails with 400 and error" $
-        request methodPatch "/simple_pk" [] "}{ x = 2"
-          `shouldRespondWith`
-          [json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
-          { matchStatus  = 400,
-            matchHeaders = [matchContentTypeJson]
-          }
-
-    context "with no payload" $
-      it "fails with 400 and error" $
-        request methodPatch "/items" [] ""
-          `shouldRespondWith`
-          [json|{"message":"Error in $: not enough input"}|]
-          { matchStatus  = 400,
-            matchHeaders = [matchContentTypeJson]
-          }
-
-    context "in a nonempty table" $ do
-      it "can update a single item" $ do
-        patch "/items?id=eq.2"
-            [json| { "id":42 } |]
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 204
-            , matchHeaders = ["Content-Range" <:> "0-0/*"]
-            }
-
-      it "returns empty array when no rows updated and return=rep" $
-        request methodPatch "/items?id=eq.999999"
-          [("Prefer", "return=representation")] [json| { "id":999999 } |]
-          `shouldRespondWith` "[]"
-          {
-            matchStatus  = 404,
-            matchHeaders = []
-          }
-
-      it "gives a 404 when no rows updated" $
-        request methodPatch "/items?id=eq.99999999" []
-          [json| { "id": 42 } |]
-            `shouldRespondWith` 404
-
-      it "returns updated object as array when return=rep" $
-        request methodPatch "/items?id=eq.2"
-          [("Prefer", "return=representation")] [json| { "id":2 } |]
-          `shouldRespondWith` [json|[{"id":2}]|]
-          { matchStatus  = 200,
-            matchHeaders = ["Content-Range" <:> "0-0/*"]
-          }
-
-      it "can update multiple items" $ do
-        get "/no_pk?select=a&b=eq.1"
-          `shouldRespondWith`
-            [json|[]|]
-
-        request methodPatch "/no_pk?b=eq.0"
-            [("Prefer", "tx=commit")]
-            [json| { b: "1" } |]
-          `shouldRespondWith`
-            ""
-            { matchStatus  = 204
-            , matchHeaders = ["Content-Range" <:> "0-1/*"
-                             , "Preference-Applied" <:> "tx=commit" ]
-            }
-
-        -- check it really got updated
-        get "/no_pk?select=a&b=eq.1"
-          `shouldRespondWith`
-            [json|[ { a: "1" }, { a: "2" } ]|]
-
-        -- put value back for other tests
-        request methodPatch "/no_pk?b=eq.1"
-            [("Prefer", "tx=commit")]
-            [json| { b: "0" } |]
-          `shouldRespondWith`
-            204
-
-      it "can set a column to NULL" $ do
-        request methodPatch "/no_pk?a=eq.1"
-            [("Prefer", "return=representation")]
-            [json| { b: null } |]
-          `shouldRespondWith`
-            [json| [{ a: "1", b: null }] |]
-
-      context "filtering by a computed column" $ do
-        it "is successful" $
-          request methodPatch
-            "/items?is_first=eq.true"
-            [("Prefer", "return=representation")]
-            [json| { id: 100 } |]
-            `shouldRespondWith` [json| [{ id: 100 }] |]
-            { matchStatus  = 200,
-              matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
-            }
-
-        it "indicates no records updated by returning 404" $
-          request methodPatch
-            "/items?always_true=eq.false"
-            [("Prefer", "return=representation")]
-            [json| { id: 100 } |]
-            `shouldRespondWith` "[]"
-            { matchStatus  = 404,
-              matchHeaders = []
-            }
-
-      context "with representation requested" $ do
-        it "can provide a representation" $ do
-          _ <- post "/items"
-            [json| { id: 1 } |]
-          request methodPatch
-            "/items?id=eq.1"
-            [("Prefer", "return=representation")]
-            [json| { id: 99 } |]
-            `shouldRespondWith` [json| [{id:99}] |]
-            { matchHeaders = [matchContentTypeJson] }
-          -- put value back for other tests
-          void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]
-
-        it "can return computed columns" $
-          request methodPatch
-            "/items?id=eq.1&select=id,always_true"
-            [("Prefer", "return=representation")]
-            [json| { id: 1 } |]
-            `shouldRespondWith` [json| [{ id: 1, always_true: true }] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-        it "can select overloaded computed columns" $ do
-          request methodPatch
-            "/items?id=eq.1&select=id,computed_overload"
-            [("Prefer", "return=representation")]
-            [json| { id: 1 } |]
-            `shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
-            { matchHeaders = [matchContentTypeJson] }
-          request methodPatch
-            "/items2?id=eq.1&select=id,computed_overload"
-            [("Prefer", "return=representation")]
-            [json| { id: 1 } |]
-            `shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
-            { matchHeaders = [matchContentTypeJson] }
-
-      it "ignores ?select= when return not set or return=minimal" $ do
-        request methodPatch "/items?id=eq.1&select=id" [] [json| { id:1 } |]
-          `shouldRespondWith` ""
-          {
-            matchStatus  = 204,
-            matchHeaders = ["Content-Range" <:> "0-0/*"]
-          }
-        request methodPatch "/items?id=eq.1&select=id" [("Prefer", "return=minimal")] [json| { id:1 } |]
-          `shouldRespondWith` ""
-          {
-            matchStatus  = 204,
-            matchHeaders = ["Content-Range" <:> "0-0/*"]
-          }
-
-      context "when patching with an empty body" $ do
-        it "makes no updates and returns 204 without return= and without ?select=" $ do
-          request methodPatch "/items" [] [json| {} |]
-            `shouldRespondWith` ""
-            {
-              matchStatus  = 204,
-              matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-          request methodPatch "/items" [] [json| [] |]
-            `shouldRespondWith` ""
-            {
-              matchStatus  = 204,
-              matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-          request methodPatch "/items" [] [json| [{}] |]
-            `shouldRespondWith` ""
-            {
-              matchStatus  = 204,
-              matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-        it "makes no updates and returns 204 without return= and with ?select=" $ do
-          request methodPatch "/items?select=id" [] [json| {} |]
-            `shouldRespondWith` ""
-            {
-              matchStatus  = 204,
-              matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-          request methodPatch "/items?select=id" [] [json| [] |]
-            `shouldRespondWith` ""
-            {
-              matchStatus  = 204,
-              matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-          request methodPatch "/items?select=id" [] [json| [{}] |]
-            `shouldRespondWith` ""
-            {
-              matchStatus  = 204,
-              matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-        it "makes no updates and returns 200 with return=rep and without ?select=" $
-          request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
-            `shouldRespondWith` "[]"
-            {
-              matchStatus  = 200,
-              matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-        it "makes no updates and returns 200 with return=rep and with ?select=" $
-          request methodPatch "/items?select=id" [("Prefer", "return=representation")] [json| {} |]
-            `shouldRespondWith` "[]"
-            {
-              matchStatus  = 200,
-              matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-        it "makes no updates and returns 200 with return=rep and with ?select= for overloaded computed columns" $
-          request methodPatch "/items?select=id,computed_overload" [("Prefer", "return=representation")] [json| {} |]
-            `shouldRespondWith` "[]"
-            {
-              matchStatus  = 200,
-              matchHeaders = ["Content-Range" <:> "*/*"]
-            }
-
-    context "with unicode values" $
-      it "succeeds and returns values intact" $ do
-        request methodPatch "/no_pk?a=eq.1"
-            [("Prefer", "return=representation")]
-            [json| { "a":"圍棋", "b":"￥" } |]
-          `shouldRespondWith`
-            [json|[ { "a":"圍棋", "b":"￥" } ]|]
-
-    context "PATCH with ?columns parameter" $ do
-      it "ignores json keys not included in ?columns" $ do
-        request methodPatch "/articles?id=eq.1&columns=body"
-            [("Prefer", "return=representation")]
-            [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |]
-          `shouldRespondWith`
-            [json|[{"id": 1, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
-
-      it "ignores json keys and gives 404 if no record updated" $
-        request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
-          [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404
-
-  context "tables with self reference foreign keys" $ do
-    it "embeds children after update" $
-      request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"
-              [("Prefer", "return=representation")]
-        [json|{"name": "tardis-patched"}|]
-        `shouldRespondWith`
-        [json|
-          [ { "id": 0, "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
-        |]
-        { matchStatus  = 200,
-          matchHeaders = [matchContentTypeJson]
-        }
-
-    it "embeds parent, children and grandchildren after update" $
-      request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name,web_content(name)),parent_content:p_web_id(name)"
-              [("Prefer", "return=representation")]
-        [json|{"name": "tardis-patched-2"}|]
-        `shouldRespondWith`
-        [json| [
-          {
-            "id": 0,
-            "name": "tardis-patched-2",
-            "parent_content": { "name": "wat" },
-            "web_content": [
-                { "name": "fezz", "web_content": [ { "name": "wut" } ] },
-                { "name": "foo",  "web_content": [] },
-                { "name": "bar",  "web_content": [] }
-            ]
-          }
-        ] |]
-        { matchStatus  = 200,
-          matchHeaders = [matchContentTypeJson]
-        }
-
-    it "embeds children after update without explicitly including the id in the ?select" $
-      request methodPatch "/web_content?id=eq.0&select=name,web_content(name)"
-              [("Prefer", "return=representation")]
-        [json|{"name": "tardis-patched"}|]
-        `shouldRespondWith`
-        [json|
-          [ { "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
-        |]
-        { matchStatus  = 200,
-          matchHeaders = [matchContentTypeJson]
-        }
-
-    it "embeds an M2M relationship plus parent after update" $
-      request methodPatch "/users?id=eq.1&select=name,tasks(name,project:projects(name))"
-              [("Prefer", "return=representation")]
-        [json|{"name": "Kevin Malone"}|]
-        `shouldRespondWith`
-        [json|[
-          {
-            "name": "Kevin Malone",
-            "tasks": [
-                { "name": "Design w7", "project": { "name": "Windows 7" } },
-                { "name": "Code w7", "project": { "name": "Windows 7" } },
-                { "name": "Design w10", "project": { "name": "Windows 10" } },
-                { "name": "Code w10", "project": { "name": "Windows 10" } }
-            ]
-          }
-        ]|]
-        { matchStatus  = 200,
-          matchHeaders = [matchContentTypeJson]
-        }
-
-  context "table with limited privileges" $ do
-    it "succeeds updating row and gives a 204 when using return=minimal" $
-      request methodPatch "/app_users?id=eq.1" [("Prefer", "return=minimal")]
-        [json| { "password": "passxyz" } |]
-          `shouldRespondWith` 204
-
-    it "can update without return=minimal and no explicit select" $
-      request methodPatch "/app_users?id=eq.1" []
-        [json| { "password": "passabc" } |]
-          `shouldRespondWith` 204
diff --git a/test/Feature/UpsertSpec.hs b/test/Feature/UpsertSpec.hs
deleted file mode 100644
--- a/test/Feature/UpsertSpec.hs
+++ /dev/null
@@ -1,427 +0,0 @@
-module Feature.UpsertSpec where
-
-import Network.Wai (Application)
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Test.Hspec.Wai.JSON
-
-import PostgREST.Config.PgVersion (PgVersion, pgVersion110)
-
-import Protolude  hiding (get, put)
-import SpecHelper
-
-spec :: PgVersion -> SpecWith ((), Application)
-spec actualPgVersion =
-  describe "UPSERT" $ do
-    context "with POST" $ do
-      context "when Prefer: resolution=merge-duplicates is specified" $ do
-        it "INSERTs and UPDATEs rows on pk conflict" $
-          request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
-            [json| [
-              { "name": "Javascript", "rank": 6 },
-              { "name": "Java", "rank": 2 },
-              { "name": "C", "rank": 1 }
-            ]|] `shouldRespondWith` [json| [
-              { "name": "Javascript", "rank": 6 },
-              { "name": "Java", "rank": 2 },
-              { "name": "C", "rank": 1 }
-            ]|]
-            { matchStatus = 201
-            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
-            }
-
-        it "INSERTs and UPDATEs row on composite pk conflict" $
-          request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
-            [json| [
-              { "first_name": "Frances M.", "last_name": "Roe", "salary": "30000" },
-              { "first_name": "Peter S.", "last_name": "Yang", "salary": 42000 }
-            ]|] `shouldRespondWith` [json| [
-              { "first_name": "Frances M.", "last_name": "Roe", "salary": "$30,000.00", "company": "One-Up Realty", "occupation": "Author" },
-              { "first_name": "Peter S.", "last_name": "Yang", "salary": "$42,000.00", "company": null, "occupation": null }
-            ]|]
-            { matchStatus = 201
-            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
-            }
-
-        when (actualPgVersion >= pgVersion110) $
-          it "INSERTs and UPDATEs rows on composite pk conflict for partitioned tables" $
-            request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
-              [json| [
-                { "name": "Murcielago", "year": 2001, "car_brand_name": null},
-                { "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }
-              ]|] `shouldRespondWith` [json| [
-                { "name": "Murcielago", "year": 2001, "car_brand_name": null},
-                { "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }
-              ]|]
-              { matchStatus = 201
-              , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
-              }
-
-        it "succeeds when the payload has no elements" $
-          request methodPost "/articles" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
-            [json|[]|] `shouldRespondWith`
-            [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
-
-        it "INSERTs and UPDATEs rows on single unique key conflict" $
-          request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
-            [json| [
-              { "unique_key": 1, "value": "B" },
-              { "unique_key": 2, "value": "C" }
-            ]|] `shouldRespondWith` [json| [
-              { "unique_key": 1, "value": "B" },
-              { "unique_key": 2, "value": "C" }
-            ]|]
-            { matchStatus = 201
-            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
-            }
-
-        it "INSERTs and UPDATEs rows on compound unique keys conflict" $
-          request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
-            [json| [
-              { "key1": 1, "key2": 1, "value": "B" },
-              { "key1": 1, "key2": 2, "value": "C" }
-            ]|] `shouldRespondWith` [json| [
-              { "key1": 1, "key2": 1, "value": "B" },
-              { "key1": 1, "key2": 2, "value": "C" }
-            ]|]
-            { matchStatus = 201
-            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
-            }
-
-      context "when Prefer: resolution=ignore-duplicates is specified" $ do
-        it "INSERTs and ignores rows on pk conflict" $
-          request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
-            [json|[
-              { "name": "PHP", "rank": 9 },
-              { "name": "Python", "rank": 10 }
-            ]|] `shouldRespondWith` [json|[
-              { "name": "PHP", "rank": 9 }
-            ]|]
-            { matchStatus = 201
-            , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
-            }
-
-        it "INSERTs and ignores rows on composite pk conflict" $
-          request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
-            [json|[
-              { "first_name": "Daniel B.", "last_name": "Lyon", "salary": "72000", "company": null, "occupation": null },
-              { "first_name": "Sara M.", "last_name": "Torpey", "salary": 60000, "company": "Burstein-Applebee", "occupation": "Soil scientist" }
-            ]|] `shouldRespondWith` [json|[
-              { "first_name": "Sara M.", "last_name": "Torpey", "salary": "$60,000.00", "company": "Burstein-Applebee", "occupation": "Soil scientist" }
-            ]|]
-            { matchStatus = 201
-            , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
-            }
-
-        when (actualPgVersion >= pgVersion110) $
-          it "INSERTs and ignores rows on composite pk conflict for partitioned tables" $
-            request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
-              [json| [
-                { "name": "Murcielago", "year": 2001, "car_brand_name": "Ferrari" },
-                { "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }
-              ]|] `shouldRespondWith` [json| [
-                { "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }
-              ]|]
-              { matchStatus = 201
-              , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
-              }
-
-        it "INSERTs and ignores rows on single unique key conflict" $
-          request methodPost "/single_unique?on_conflict=unique_key"
-              [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
-              [json| [
-                { "unique_key": 1, "value": "B" },
-                { "unique_key": 2, "value": "C" },
-                { "unique_key": 3, "value": "D" }
-              ]|]
-            `shouldRespondWith`
-              [json| [
-                { "unique_key": 2, "value": "C" },
-                { "unique_key": 3, "value": "D" }
-              ]|]
-              { matchStatus = 201
-              , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
-              }
-
-        it "INSERTs and UPDATEs rows on compound unique keys conflict" $
-          request methodPost "/compound_unique?on_conflict=key1,key2"
-              [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
-              [json| [
-                { "key1": 1, "key2": 1, "value": "B" },
-                { "key1": 1, "key2": 2, "value": "C" },
-                { "key1": 1, "key2": 3, "value": "D" }
-              ]|]
-            `shouldRespondWith`
-              [json| [
-                { "key1": 1, "key2": 2, "value": "C" },
-                { "key1": 1, "key2": 3, "value": "D" }
-              ]|]
-              { matchStatus = 201
-              , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
-              }
-
-      it "succeeds if the table has only PK cols and no other cols" $ do
-        request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
-          [json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]
-          `shouldRespondWith`
-          [json|[ { "id": 3} ]|]
-          { matchStatus = 201 ,
-            matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates",
-            matchContentTypeJson] }
-
-        request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
-          [json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
-          `shouldRespondWith`
-          [json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
-          { matchStatus = 201 ,
-            matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates",
-            matchContentTypeJson] }
-
-      it "succeeds and ignores the Prefer: resolution header(no Preference-Applied present) if the table has no PK" $
-        request methodPost "/no_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
-          [json|[ { "a": "1", "b": "0" } ]|]
-          `shouldRespondWith`
-          [json|[ { "a": "1", "b": "0" } ]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
-
-      it "succeeds if not a single resource is created" $ do
-        request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
-          [json|[ { "name": "Java", "rank": 1 } ]|] `shouldRespondWith`
-          [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
-        request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
-          [json|[ { "name": "Java", "rank": 1 }, { "name": "C", "rank": 2 } ]|] `shouldRespondWith`
-          [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
-
-    context "with PUT" $ do
-      context "Restrictions" $ do
-        it "fails if Range is specified" $
-          request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]
-            [json| [ { "name": "Javascript", "rank": 1 } ]|]
-            `shouldRespondWith`
-            [json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
-            { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
-
-        it "fails if limit is specified" $
-          put "/tiobe_pls?name=eq.Javascript&limit=1"
-            [json| [ { "name": "Javascript", "rank": 1 } ]|]
-            `shouldRespondWith`
-            [json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
-            { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
-
-        it "fails if offset is specified" $
-          put "/tiobe_pls?name=eq.Javascript&offset=1"
-            [json| [ { "name": "Javascript", "rank": 1 } ]|]
-            `shouldRespondWith`
-            [json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
-            { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
-
-        it "rejects every other filter than pk cols eq's" $ do
-          put "/tiobe_pls?rank=eq.19"
-            [json| [ { "name": "Go", "rank": 19 } ]|]
-            `shouldRespondWith`
-            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
-            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
-
-          put "/tiobe_pls?id=not.eq.Java"
-            [json| [ { "name": "Go", "rank": 19 } ]|]
-            `shouldRespondWith`
-            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
-            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
-          put "/tiobe_pls?id=in.(Go)"
-            [json| [ { "name": "Go", "rank": 19 } ]|]
-            `shouldRespondWith`
-            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
-            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
-          put "/tiobe_pls?and=(id.eq.Go)"
-            [json| [ { "name": "Go", "rank": 19 } ]|]
-            `shouldRespondWith`
-            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
-            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
-
-        it "fails if not all composite key cols are specified as eq filters" $ do
-          put "/employees?first_name=eq.Susan"
-            [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
-            `shouldRespondWith`
-            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
-            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
-          put "/employees?last_name=eq.Heidt"
-            [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
-            `shouldRespondWith`
-            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
-            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
-
-      it "fails if the uri primary key doesn't match the payload primary key" $ do
-        put "/tiobe_pls?name=eq.MATLAB" [json| [ { "name": "Perl", "rank": 17 } ]|]
-          `shouldRespondWith`
-          [json|{"message":"Payload values do not match URL in primary key column(s)"}|]
-          { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
-        put "/employees?first_name=eq.Wendy&last_name=eq.Anderson"
-          [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
-          `shouldRespondWith`
-          [json|{"message":"Payload values do not match URL in primary key column(s)"}|]
-          { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
-
-      it "fails if the table has no PK" $
-        put "/no_pk?a=eq.one&b=eq.two" [json| [ { "a": "one", "b": "two" } ]|]
-          `shouldRespondWith`
-          [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
-          { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
-
-      context "Inserting row" $ do
-        it "succeeds on table with single pk col" $ do
-          -- assert that the next request will indeed be an insert
-          get "/tiobe_pls?name=eq.Go"
-            `shouldRespondWith`
-              [json|[]|]
-
-          request methodPut "/tiobe_pls?name=eq.Go"
-              [("Prefer", "return=representation")]
-              [json| [ { "name": "Go", "rank": 19 } ]|]
-            `shouldRespondWith`
-              [json| [ { "name": "Go", "rank": 19 } ]|]
-
-        it "succeeds on table with composite pk" $ do
-          -- assert that the next request will indeed be an insert
-          get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
-            `shouldRespondWith`
-              [json|[]|]
-
-          request methodPut "/employees?first_name=eq.Susan&last_name=eq.Heidt"
-              [("Prefer", "return=representation")]
-              [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
-            `shouldRespondWith`
-              [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
-
-        when (actualPgVersion >= pgVersion110) $
-          it "succeeds on a partitioned table with composite pk" $ do
-            -- assert that the next request will indeed be an insert
-            get "/car_models?name=eq.Supra&year=eq.2021"
-              `shouldRespondWith`
-                [json|[]|]
-
-            request methodPut "/car_models?name=eq.Supra&year=eq.2021"
-                [("Prefer", "return=representation")]
-                [json| [ { "name": "Supra", "year": 2021 } ]|]
-              `shouldRespondWith`
-                [json| [ { "name": "Supra", "year": 2021, "car_brand_name": null } ]|]
-
-        it "succeeds if the table has only PK cols and no other cols" $ do
-          -- assert that the next request will indeed be an insert
-          get "/only_pk?id=eq.10"
-            `shouldRespondWith`
-              [json|[]|]
-
-          request methodPut "/only_pk?id=eq.10"
-              [("Prefer", "return=representation")]
-              [json|[ { "id": 10 } ]|]
-            `shouldRespondWith`
-              [json|[ { "id": 10 } ]|]
-
-      context "Updating row" $ do
-        it "succeeds on table with single pk col" $ do
-          -- assert that the next request will indeed be an update
-          get "/tiobe_pls?name=eq.Java"
-            `shouldRespondWith`
-              [json|[ { "name": "Java", "rank": 1 } ]|]
-
-          request methodPut "/tiobe_pls?name=eq.Java"
-              [("Prefer", "return=representation")]
-              [json| [ { "name": "Java", "rank": 13 } ]|]
-            `shouldRespondWith`
-              [json| [ { "name": "Java", "rank": 13 } ]|]
-
-        -- TODO: move this to SingularSpec?
-        it "succeeds if the payload has more than one row, but it only puts the first element" $ do
-          -- assert that the next request will indeed be an update
-          get "/tiobe_pls?name=eq.Java"
-            `shouldRespondWith`
-              [json|[ { "name": "Java", "rank": 1 } ]|]
-
-          request methodPut "/tiobe_pls?name=eq.Java"
-              [("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
-              [json| [ { "name": "Java", "rank": 19 }, { "name": "Swift", "rank": 12 } ] |]
-            `shouldRespondWith`
-              [json|{ "name": "Java", "rank": 19 }|]
-              { matchHeaders = [matchContentTypeSingular] }
-
-        it "succeeds on table with composite pk" $ do
-          -- assert that the next request will indeed be an update
-          get "/employees?first_name=eq.Frances M.&last_name=eq.Roe"
-            `shouldRespondWith`
-              [json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$24,000.00", "company": "One-Up Realty", "occupation": "Author" } ]|]
-
-          request methodPut "/employees?first_name=eq.Frances M.&last_name=eq.Roe"
-              [("Prefer", "return=representation")]
-              [json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "60000", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
-            `shouldRespondWith`
-              [json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
-
-        when (actualPgVersion >= pgVersion110) $
-          it "succeeds on a partitioned table with composite pk" $ do
-            -- assert that the next request will indeed be an update
-            get "/car_models?name=eq.DeLorean&year=eq.1981"
-              `shouldRespondWith`
-                [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": "DMC" } ]|]
-
-            request methodPut "/car_models?name=eq.DeLorean&year=eq.1981"
-                [("Prefer", "return=representation")]
-                [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]
-              `shouldRespondWith`
-                [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]
-
-        it "succeeds if the table has only PK cols and no other cols" $ do
-          -- assert that the next request will indeed be an update
-          get "/only_pk?id=eq.1"
-            `shouldRespondWith`
-              [json|[ { "id": 1 } ]|]
-
-          request methodPut "/only_pk?id=eq.1"
-              [("Prefer", "return=representation")]
-              [json|[ { "id": 1 } ]|]
-            `shouldRespondWith`
-              [json|[ { "id": 1 } ]|]
-
-      -- TODO: move this to SingularSpec?
-      it "works with return=representation and vnd.pgrst.object+json" $
-        request methodPut "/tiobe_pls?name=eq.Ruby"
-          [("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
-          [json| [ { "name": "Ruby", "rank": 11 } ]|]
-          `shouldRespondWith` [json|{ "name": "Ruby", "rank": 11 }|] { matchHeaders = [matchContentTypeSingular] }
-
-    context "with a camel case pk column" $ do
-      it "works with POST and merge-duplicates" $ do
-        request methodPost "/UnitTest"
-            [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
-            [json|[
-              { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
-              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
-            ]|]
-          `shouldRespondWith`
-            [json|[
-              { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
-              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
-            ]|]
-            { matchStatus = 201
-            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates"]
-            }
-
-      it "works with POST and ignore-duplicates headers" $ do
-        request methodPost "/UnitTest"
-            [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
-            [json|[
-              { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
-              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
-            ]|]
-          `shouldRespondWith`
-            [json|[
-              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
-            ]|]
-            { matchStatus = 201
-            , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
-            }
-
-      it "works with PUT" $ do
-        put "/UnitTest?idUnitTest=eq.1" [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] `shouldRespondWith` 204
-        get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`
-          [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|]
diff --git a/test/Main.hs b/test/Main.hs
deleted file mode 100644
--- a/test/Main.hs
+++ /dev/null
@@ -1,236 +0,0 @@
-module Main where
-
-import qualified Data.Aeson                 as JSON
-import qualified Hasql.Pool                 as P
-import qualified Hasql.Transaction.Sessions as HT
-
-import Data.Function      (id)
-import Data.List.NonEmpty (toList)
-
-import Test.Hspec
-
-import PostgREST.App             (postgrest)
-import PostgREST.Config          (AppConfig (..), LogLevel (..))
-import PostgREST.Config.Database (queryPgVersion)
-import PostgREST.DbStructure     (queryDbStructure)
-import Protolude                 hiding (toList, toS)
-import Protolude.Conv            (toS)
-import SpecHelper
-
-import qualified PostgREST.AppState as AppState
-
-import qualified Feature.AndOrParamsSpec
-import qualified Feature.AsymmetricJwtSpec
-import qualified Feature.AudienceJwtSecretSpec
-import qualified Feature.AuthSpec
-import qualified Feature.BinaryJwtSecretSpec
-import qualified Feature.ConcurrentSpec
-import qualified Feature.CorsSpec
-import qualified Feature.DeleteSpec
-import qualified Feature.DisabledOpenApiSpec
-import qualified Feature.EmbedDisambiguationSpec
-import qualified Feature.EmbedInnerJoinSpec
-import qualified Feature.ExtraSearchPathSpec
-import qualified Feature.HtmlRawOutputSpec
-import qualified Feature.IgnorePrivOpenApiSpec
-import qualified Feature.InsertSpec
-import qualified Feature.JsonOperatorSpec
-import qualified Feature.LegacyGucsSpec
-import qualified Feature.MultipleSchemaSpec
-import qualified Feature.NoJwtSpec
-import qualified Feature.NonexistentSchemaSpec
-import qualified Feature.OpenApiSpec
-import qualified Feature.OptionsSpec
-import qualified Feature.ProxySpec
-import qualified Feature.QueryLimitedSpec
-import qualified Feature.QuerySpec
-import qualified Feature.RangeSpec
-import qualified Feature.RawOutputTypesSpec
-import qualified Feature.RollbackSpec
-import qualified Feature.RootSpec
-import qualified Feature.RpcPreRequestGucsSpec
-import qualified Feature.RpcSpec
-import qualified Feature.SingularSpec
-import qualified Feature.UnicodeSpec
-import qualified Feature.UpdateSpec
-import qualified Feature.UpsertSpec
-
-
-main :: IO ()
-main = do
-  testDbConn <- getEnvVarWithDefault "PGRST_DB_URI" "postgres://postgrest_test@localhost/postgrest_test"
-
-  pool <- P.acquire (3, 10, toS testDbConn)
-
-  actualPgVersion <- either (panic.show) id <$> P.use pool queryPgVersion
-
-  baseDbStructure <-
-    loadDbStructure pool
-      (configDbSchemas $ testCfg testDbConn)
-      (configDbExtraSearchPath $ testCfg testDbConn)
-      actualPgVersion
-
-  let
-    -- For tests that run with the same refDbStructure
-    app cfg = do
-      let config = cfg testDbConn
-      appState <- AppState.initWithPool pool config
-      AppState.putPgVersion appState actualPgVersion
-      AppState.putDbStructure appState baseDbStructure
-      when (isJust $ configDbRootSpec config) $
-        AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure
-      return ((), postgrest LogCrit appState $ pure ())
-
-    -- For tests that run with a different DbStructure(depends on configSchemas)
-    appDbs cfg = do
-      let config = cfg testDbConn
-      customDbStructure <-
-        loadDbStructure pool
-          (configDbSchemas config)
-          (configDbExtraSearchPath config)
-          actualPgVersion
-      appState <- AppState.initWithPool pool config
-      AppState.putPgVersion appState actualPgVersion
-      AppState.putDbStructure appState customDbStructure
-      when (isJust $ configDbRootSpec config) $
-        AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure
-      return ((), postgrest LogCrit appState $ pure ())
-
-  let withApp              = app testCfg
-      maxRowsApp           = app testMaxRowsCfg
-      disabledOpenApi      = app testDisabledOpenApiCfg
-      proxyApp             = app testProxyCfg
-      noJwtApp             = app testCfgNoJWT
-      binaryJwtApp         = app testCfgBinaryJWT
-      audJwtApp            = app testCfgAudienceJWT
-      asymJwkApp           = app testCfgAsymJWK
-      asymJwkSetApp        = app testCfgAsymJWKSet
-      rootSpecApp          = app testCfgRootSpec
-      htmlRawOutputApp     = app testCfgHtmlRawOutput
-      responseHeadersApp   = app testCfgResponseHeaders
-      disallowRollbackApp  = app testCfgDisallowRollback
-      forceRollbackApp     = app testCfgForceRollback
-      testCfgLegacyGucsApp = app testCfgLegacyGucs
-
-      extraSearchPathApp   = appDbs testCfgExtraSearchPath
-      unicodeApp           = appDbs testUnicodeCfg
-      nonexistentSchemaApp = appDbs testNonexistentSchemaCfg
-      multipleSchemaApp    = appDbs testMultipleSchemaCfg
-      ignorePrivOpenApi    = appDbs testIgnorePrivOpenApiCfg
-
-  let analyze :: IO ()
-      analyze = do
-        analyzeTable testDbConn "items"
-        analyzeTable testDbConn "child_entities"
-
-      specs = uncurry describe <$> [
-          ("Feature.AndOrParamsSpec"         , Feature.AndOrParamsSpec.spec actualPgVersion)
-        , ("Feature.AuthSpec"                , Feature.AuthSpec.spec actualPgVersion)
-        , ("Feature.ConcurrentSpec"          , Feature.ConcurrentSpec.spec)
-        , ("Feature.CorsSpec"                , Feature.CorsSpec.spec)
-        , ("Feature.DeleteSpec"              , Feature.DeleteSpec.spec)
-        , ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)
-        , ("Feature.EmbedInnerJoinSpec"      , Feature.EmbedInnerJoinSpec.spec)
-        , ("Feature.InsertSpec"              , Feature.InsertSpec.spec actualPgVersion)
-        , ("Feature.JsonOperatorSpec"        , Feature.JsonOperatorSpec.spec actualPgVersion)
-        , ("Feature.OpenApiSpec"             , Feature.OpenApiSpec.spec actualPgVersion)
-        , ("Feature.OptionsSpec"             , Feature.OptionsSpec.spec actualPgVersion)
-        , ("Feature.QuerySpec"               , Feature.QuerySpec.spec actualPgVersion)
-        , ("Feature.RawOutputTypesSpec"      , Feature.RawOutputTypesSpec.spec)
-        , ("Feature.RpcSpec"                 , Feature.RpcSpec.spec actualPgVersion)
-        , ("Feature.SingularSpec"            , Feature.SingularSpec.spec)
-        , ("Feature.UpdateSpec"              , Feature.UpdateSpec.spec)
-        , ("Feature.UpsertSpec"              , Feature.UpsertSpec.spec actualPgVersion)
-        ]
-
-  hspec $ do
-    mapM_ (parallel . before withApp) specs
-
-    -- we analyze to get accurate results from EXPLAIN
-    parallel $ beforeAll_ analyze . before withApp $
-      describe "Feature.RangeSpec" Feature.RangeSpec.spec
-
-    -- this test runs with a raw-output-media-types set to text/html
-    parallel $ before htmlRawOutputApp $
-      describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec
-
-    -- this test runs with a different server flag
-    parallel $ before maxRowsApp $
-      describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec
-
-    -- this test runs with a different schema
-    parallel $ before unicodeApp $
-      describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec
-
-    -- this test runs with openapi-mode set to disabled
-    parallel $ before disabledOpenApi $
-      describe "Feature.DisabledOpenApiSpec" Feature.DisabledOpenApiSpec.spec
-
-    -- this test runs with openapi-mode set to ignore-acl
-    parallel $ before ignorePrivOpenApi $
-      describe "Feature.IgnorePrivOpenApiSpec" Feature.IgnorePrivOpenApiSpec.spec
-
-    -- this test runs with a proxy
-    parallel $ before proxyApp $
-      describe "Feature.ProxySpec" Feature.ProxySpec.spec
-
-    -- this test runs without a JWT secret
-    parallel $ before noJwtApp $
-      describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec
-
-    -- this test runs with a binary JWT secret
-    parallel $ before binaryJwtApp $
-      describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec
-
-    -- this test runs with a binary JWT secret and an audience claim
-    parallel $ before audJwtApp $
-      describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec
-
-    -- this test runs with asymmetric JWK
-    parallel $ before asymJwkApp $
-      describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-
-    -- this test runs with asymmetric JWKSet
-    parallel $ before asymJwkSetApp $
-      describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-
-    -- this test runs with a nonexistent db-schema
-    parallel $ before nonexistentSchemaApp $
-      describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
-
-    -- this test runs with an extra search path
-    parallel $ before extraSearchPathApp $
-      describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec
-
-    -- this test runs with a root spec function override
-    parallel $ before rootSpecApp $
-      describe "Feature.RootSpec" Feature.RootSpec.spec
-    parallel $ before responseHeadersApp $
-      describe "Feature.RpcPreRequestGucsSpec" Feature.RpcPreRequestGucsSpec.spec
-
-    -- this test runs with multiple schemas
-    parallel $ before multipleSchemaApp $
-      describe "Feature.MultipleSchemaSpec" Feature.MultipleSchemaSpec.spec
-
-    -- this test runs with db-uses-legacy-gucs = false
-    parallel $ before testCfgLegacyGucsApp $
-      describe "Feature.LegacyGucsSpec" Feature.LegacyGucsSpec.spec
-
-    -- Note: the rollback tests can not run in parallel, because they test persistance and
-    -- this results in race conditions
-
-    -- this test runs with tx-rollback-all = true and tx-allow-override = true
-    before withApp $
-      describe"Feature.RollbackAllowedSpec" Feature.RollbackSpec.allowed
-
-    -- this test runs with tx-rollback-all = false and tx-allow-override = false
-    before disallowRollbackApp $
-      describe "Feature.RollbackDisallowedSpec" Feature.RollbackSpec.disallowed
-
-    -- this test runs with tx-rollback-all = true and tx-allow-override = false
-    before forceRollbackApp $
-      describe "Feature.RollbackForcedSpec" Feature.RollbackSpec.forced
-
-  where
-    loadDbStructure pool schemas extraSearchPath actualPgVersion =
-      either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ queryDbStructure (toList schemas) extraSearchPath actualPgVersion True)
diff --git a/test/QueryCost.hs b/test/QueryCost.hs
deleted file mode 100644
--- a/test/QueryCost.hs
+++ /dev/null
@@ -1,89 +0,0 @@
-module Main where
-
-import           Control.Lens                      ((^?))
-import qualified Data.Aeson.Lens                   as L
-import qualified Hasql.Decoders                    as HD
-import qualified Hasql.DynamicStatements.Snippet   as H
-import qualified Hasql.DynamicStatements.Statement as H
-import qualified Hasql.Pool                        as P
-import qualified Hasql.Statement                   as H
-import qualified Hasql.Transaction                 as HT
-import qualified Hasql.Transaction.Sessions        as HT
-import           Text.Heredoc
-
-import Protolude      hiding (get, toS)
-import Protolude.Conv (toS)
-
-import PostgREST.Query.QueryBuilder (requestToCallProcQuery)
-import PostgREST.Request.Types
-
-import PostgREST.DbStructure.Identifiers
-import PostgREST.DbStructure.Proc
-
-import SpecHelper (getEnvVarWithDefault)
-
-import Test.Hspec
-
-main :: IO ()
-main = do
-  testDbConn <- getEnvVarWithDefault "PGRST_DB_URI" "postgres://postgrest_test@localhost/postgrest_test"
-  pool <- P.acquire (3, 10, toS testDbConn)
-
-  hspec $ describe "QueryCost" $
-    context "call proc query" $ do
-      it "should not exceed cost when calling setof composite proc" $ do
-        cost <- exec pool $
-          requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
-          (KeyParams [ProcParam "id" "int" True False])
-          (Just [str| {"id": 3} |]) False False [])
-        liftIO $
-          cost `shouldSatisfy` (< Just 40)
-
-      it "should not exceed cost when calling setof composite proc with empty params" $ do
-        cost <- exec pool $
-          requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") (KeyParams []) Nothing False False [])
-        liftIO $
-          cost `shouldSatisfy` (< Just 30)
-
-      it "should not exceed cost when calling scalar proc" $ do
-        cost <- exec pool $
-          requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
-          (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
-          (Just [str| {"a": 3, "b": 4} |]) True False [])
-        liftIO $
-          cost `shouldSatisfy` (< Just 10)
-
-      context "params=multiple-objects" $ do
-        it "should not exceed cost when calling setof composite proc" $ do
-          cost <- exec pool $
-            requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
-            (KeyParams [ProcParam "id" "int" True False])
-            (Just [str| [{"id": 1}, {"id": 4}] |]) False True [])
-          liftIO $ do
-            -- lower bound needed for now to make sure that cost is not Nothing
-            cost `shouldSatisfy` (> Just 2000)
-            cost `shouldSatisfy` (< Just 2100)
-
-        it "should not exceed cost when calling scalar proc" $ do
-          cost <- exec pool $
-            requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
-            (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
-            (Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False [])
-          liftIO $
-            cost `shouldSatisfy` (< Just 10)
-
-
-exec :: P.Pool -> H.Snippet -> IO (Maybe Int64)
-exec pool query =
-  join . rightToMaybe <$>
-  P.use pool (HT.transaction HT.ReadCommitted HT.Read $ HT.statement mempty $ explainCost query)
-
-explainCost :: H.Snippet -> H.Statement () (Maybe Int64)
-explainCost query =
-  H.dynamicallyParameterized snippet decodeExplain False
- where
-   snippet = "EXPLAIN (FORMAT JSON) " <> query
-   decodeExplain :: HD.Result (Maybe Int64)
-   decodeExplain =
-     let row = HD.singleRow $ HD.column $ HD.nonNullable HD.bytea in
-     (^? L.nth 0 . L.key "Plan" .  L.key "Total Cost" . L._Integral) <$> row
diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs
deleted file mode 100644
--- a/test/SpecHelper.hs
+++ /dev/null
@@ -1,246 +0,0 @@
-module SpecHelper where
-
-import qualified Data.ByteString.Base64 as B64 (decodeLenient, encode)
-import qualified Data.ByteString.Char8  as BS
-import qualified Data.ByteString.Lazy   as BL
-import qualified Data.Map.Strict        as M
-import qualified Data.Set               as S
-import qualified System.IO.Error        as E
-
-import Data.Aeson           (Value (..), decode, encode)
-import Data.CaseInsensitive (CI (..), original)
-import Data.List            (lookup)
-import Data.List.NonEmpty   (fromList)
-import Network.Wai.Test     (SResponse (simpleBody, simpleHeaders, simpleStatus))
-import System.Environment   (getEnv)
-import System.Process       (readProcess)
-import Text.Regex.TDFA      ((=~))
-
-
-import Network.HTTP.Types
-import Test.Hspec
-import Test.Hspec.Wai
-import Text.Heredoc
-
-import PostgREST.Config                  (AppConfig (..),
-                                          JSPathExp (..),
-                                          LogLevel (..),
-                                          OpenAPIMode (..),
-                                          parseSecret)
-import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
-import Protolude                         hiding (toS)
-import Protolude.Conv                    (toS)
-
-matchContentTypeJson :: MatchHeader
-matchContentTypeJson = "Content-Type" <:> "application/json; charset=utf-8"
-
-matchContentTypeSingular :: MatchHeader
-matchContentTypeSingular = "Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"
-
-matchHeaderAbsent :: HeaderName -> MatchHeader
-matchHeaderAbsent name = MatchHeader $ \headers _body ->
-  case lookup name headers of
-    Just _  -> Just $ "unexpected header: " <> toS (original name) <> "\n"
-    Nothing -> Nothing
-
-validateOpenApiResponse :: [Header] -> WaiSession () ()
-validateOpenApiResponse headers = do
-  r <- request methodGet "/" headers ""
-  liftIO $
-    let respStatus = simpleStatus r in
-    respStatus `shouldSatisfy`
-      \s -> s == Status { statusCode = 200, statusMessage="OK" }
-  liftIO $
-    let respHeaders = simpleHeaders r in
-    respHeaders `shouldSatisfy`
-      \hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs
-  let Just body = decode (simpleBody r)
-  Just schema <- liftIO $ decode <$> BL.readFile "test/fixtures/openapi.json"
-  let args :: M.Map Text Value
-      args = M.fromList
-        [ ( "schema", schema )
-        , ( "data", body ) ]
-      hdrs = acceptHdrs "application/json"
-  request methodPost "/rpc/validate_json_schema" hdrs (encode args)
-      `shouldRespondWith` "true"
-      { matchStatus = 200
-      , matchHeaders = []
-      }
-
-
-getEnvVarWithDefault :: Text -> Text -> IO Text
-getEnvVarWithDefault var def = toS <$>
-  getEnv (toS var) `E.catchIOError` const (return $ toS def)
-
-_baseCfg :: AppConfig
-_baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in
-  AppConfig {
-    configAppSettings           = [ ("app.settings.app_host", "localhost") , ("app.settings.external_api_secret", "0123456789abcdef") ]
-  , configDbAnonRole            = "postgrest_test_anonymous"
-  , configDbChannel             = mempty
-  , configDbChannelEnabled      = True
-  , configDbExtraSearchPath     = []
-  , configDbMaxRows             = Nothing
-  , configDbPoolSize            = 10
-  , configDbPoolTimeout         = 10
-  , configDbPreRequest          = Just $ QualifiedIdentifier "test" "switch_role"
-  , configDbPreparedStatements  = True
-  , configDbRootSpec            = Nothing
-  , configDbSchemas             = fromList ["test"]
-  , configDbConfig              = False
-  , configDbUri                 = mempty
-  , configDbUseLegacyGucs       = True
-  , configFilePath              = Nothing
-  , configJWKS                  = parseSecret <$> secret
-  , configJwtAudience           = Nothing
-  , configJwtRoleClaimKey       = [JSPKey "role"]
-  , configJwtSecret             = secret
-  , configJwtSecretIsBase64     = False
-  , configLogLevel              = LogCrit
-  , configOpenApiMode           = OAFollowPriv
-  , configOpenApiServerProxyUri = Nothing
-  , configRawMediaTypes         = []
-  , configServerHost            = "localhost"
-  , configServerPort            = 3000
-  , configServerUnixSocket      = Nothing
-  , configServerUnixSocketMode  = 432
-  , configDbTxAllowOverride     = True
-  , configDbTxRollbackAll       = True
-  }
-
-testCfg :: Text -> AppConfig
-testCfg testDbConn = _baseCfg { configDbUri = testDbConn }
-
-testCfgDisallowRollback :: Text -> AppConfig
-testCfgDisallowRollback testDbConn = (testCfg testDbConn) { configDbTxAllowOverride = False, configDbTxRollbackAll = False }
-
-testCfgForceRollback :: Text -> AppConfig
-testCfgForceRollback testDbConn = (testCfg testDbConn) { configDbTxAllowOverride = False, configDbTxRollbackAll = True }
-
-testCfgNoJWT :: Text -> AppConfig
-testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing, configJWKS = Nothing }
-
-testUnicodeCfg :: Text -> AppConfig
-testUnicodeCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["تست"] }
-
-testMaxRowsCfg :: Text -> AppConfig
-testMaxRowsCfg testDbConn = (testCfg testDbConn) { configDbMaxRows = Just 2 }
-
-testDisabledOpenApiCfg :: Text -> AppConfig
-testDisabledOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OADisabled }
-
-testIgnorePrivOpenApiCfg :: Text -> AppConfig
-testIgnorePrivOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OAIgnorePriv, configDbSchemas = fromList ["test", "v1"] }
-
-testProxyCfg :: Text -> AppConfig
-testProxyCfg testDbConn = (testCfg testDbConn) { configOpenApiServerProxyUri = Just "https://postgrest.com/openapi.json" }
-
-testCfgBinaryJWT :: Text -> AppConfig
-testCfgBinaryJWT testDbConn =
-  let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in
-  (testCfg testDbConn) {
-    configJwtSecret = secret
-  , configJWKS = parseSecret <$> secret
-  }
-
-testCfgAudienceJWT :: Text -> AppConfig
-testCfgAudienceJWT testDbConn =
-  let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in
-  (testCfg testDbConn) {
-    configJwtSecret = secret
-  , configJwtAudience = Just "youraudience"
-  , configJWKS = parseSecret <$> secret
-  }
-
-testCfgAsymJWK :: Text -> AppConfig
-testCfgAsymJWK testDbConn =
-  let secret = Just $ encodeUtf8 [str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|]
-  in (testCfg testDbConn) {
-    configJwtSecret = secret
-  , configJWKS = parseSecret <$> secret
-  }
-
-testCfgAsymJWKSet :: Text -> AppConfig
-testCfgAsymJWKSet testDbConn =
-  let secret = Just $ encodeUtf8 [str|{"keys": [{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}]}|]
-  in (testCfg testDbConn) {
-    configJwtSecret = secret
-  , configJWKS = parseSecret <$> secret
-  }
-
-testNonexistentSchemaCfg :: Text -> AppConfig
-testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["nonexistent"] }
-
-testCfgExtraSearchPath :: Text -> AppConfig
-testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configDbExtraSearchPath = ["public", "extensions"] }
-
-testCfgRootSpec :: Text -> AppConfig
-testCfgRootSpec testDbConn = (testCfg testDbConn) { configDbRootSpec = Just $ QualifiedIdentifier mempty "root"}
-
-testCfgHtmlRawOutput :: Text -> AppConfig
-testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = ["text/html"] }
-
-testCfgResponseHeaders :: Text -> AppConfig
-testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configDbPreRequest = Just $ QualifiedIdentifier mempty "custom_headers" }
-
-testMultipleSchemaCfg :: Text -> AppConfig
-testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["v1", "v2"] }
-
-testCfgLegacyGucs :: Text -> AppConfig
-testCfgLegacyGucs testDbConn = (testCfg testDbConn) { configDbUseLegacyGucs = False }
-
-resetDb :: Text -> IO ()
-resetDb dbConn = loadFixture dbConn "data"
-
-analyzeTable :: Text -> Text -> IO ()
-analyzeTable dbConn tableName =
-  void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-a", "-c", toS $ "ANALYZE test.\"" <> tableName <> "\""] []
-
-loadFixture :: Text -> FilePath -> IO()
-loadFixture dbConn name =
-  void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-q", "-f", "test/fixtures/" ++ name ++ ".sql"] []
-
-rangeHdrs :: ByteRange -> [Header]
-rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
-
-rangeHdrsWithCount :: ByteRange -> [Header]
-rangeHdrsWithCount r = ("Prefer", "count=exact") : rangeHdrs r
-
-acceptHdrs :: BS.ByteString -> [Header]
-acceptHdrs mime = [(hAccept, mime)]
-
-rangeUnit :: Header
-rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items")
-
-matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool
-matchHeader name valRegex headers =
-  maybe False (=~ valRegex) $ lookup name headers
-
-noBlankHeader :: [Header] -> Bool
-noBlankHeader = notElem mempty
-
-noProfileHeader :: [Header] -> Bool
-noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers
-
-authHeader :: BS.ByteString -> BS.ByteString -> Header
-authHeader typ creds =
-  (hAuthorization, typ <> " " <> creds)
-
-authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header
-authHeaderBasic u p =
-  authHeader "Basic" $ toS . B64.encode . toS $ u <> ":" <> p
-
-authHeaderJWT :: BS.ByteString -> Header
-authHeaderJWT = authHeader "Bearer"
-
--- | Tests whether the text can be parsed as a json object containing
--- the key "message", and optional keys "details", "hint", "code",
--- and no extraneous keys
-isErrorFormat :: BL.ByteString -> Bool
-isErrorFormat s =
-  "message" `S.member` keys &&
-    S.null (S.difference keys validKeys)
- where
-  obj = decode s :: Maybe (M.Map Text Value)
-  keys = maybe S.empty M.keysSet obj
-  validKeys = S.fromList ["message", "details", "hint", "code"]
diff --git a/test/TestTypes.hs b/test/TestTypes.hs
deleted file mode 100644
--- a/test/TestTypes.hs
+++ /dev/null
@@ -1,37 +0,0 @@
-module TestTypes (
-  IncPK(..)
-, CompoundPK(..)
-) where
-
-import           Data.Aeson ((.:))
-import qualified Data.Aeson as JSON
-
-import Protolude
-
-data IncPK = IncPK {
-  incId          :: Int
-, incNullableStr :: Maybe Text
-, incStr         :: Text
-, incInsert      :: Text
-} deriving (Eq, Show)
-
-instance JSON.FromJSON IncPK where
-  parseJSON (JSON.Object r) = IncPK <$>
-    r .: "id" <*>
-    r .: "nullable_string" <*>
-    r .: "non_nullable_string" <*>
-    r .: "inserted_at"
-  parseJSON _ = mzero
-
-data CompoundPK = CompoundPK {
-  compoundK1    :: Int
-, compoundK2    :: Text
-, compoundExtra :: Maybe Int
-} deriving (Eq, Show)
-
-instance JSON.FromJSON CompoundPK where
-  parseJSON (JSON.Object r) = CompoundPK <$>
-    r .: "k1" <*>
-    r .: "k2" <*>
-    r .: "extra"
-  parseJSON _ = mzero
diff --git a/test/doc/Main.hs b/test/doc/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/doc/Main.hs
@@ -0,0 +1,17 @@
+module Main (main) where
+
+import Test.DocTest (doctest)
+
+import Protolude
+
+
+main :: IO ()
+main =
+  doctest
+    [ "--verbose"
+    , "-XOverloadedStrings"
+    , "-XNoImplicitPrelude"
+    , "-XStandaloneDeriving"
+    , "-isrc"
+    , "src/PostgREST/Request/Preferences.hs"
+    ]
diff --git a/test/doctests/Main.hs b/test/doctests/Main.hs
deleted file mode 100644
--- a/test/doctests/Main.hs
+++ /dev/null
@@ -1,17 +0,0 @@
-module Main (main) where
-
-import Test.DocTest (doctest)
-
-import Protolude
-
-
-main :: IO ()
-main =
-  doctest
-    [ "--verbose"
-    , "-XOverloadedStrings"
-    , "-XNoImplicitPrelude"
-    , "-XStandaloneDeriving"
-    , "-isrc"
-    , "src/PostgREST/Request/Preferences.hs"
-    ]
diff --git a/test/spec/Feature/AndOrParamsSpec.hs b/test/spec/Feature/AndOrParamsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/AndOrParamsSpec.hs
@@ -0,0 +1,276 @@
+module Feature.AndOrParamsSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import PostgREST.Config.PgVersion (PgVersion, pgVersion112)
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion =
+  describe "and/or params used for complex boolean logic" $ do
+    context "used with GET" $ do
+      context "or param" $ do
+        it "can do simple logic" $
+          get "/entities?or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can negate simple logic" $
+          get "/entities?not.or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
+            [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can be combined with traditional filters" $
+          get "/entities?or=(id.eq.1,id.eq.2)&name=eq.entity 1&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
+
+      context "embedded levels" $ do
+        it "can do logic on the second level" $
+          get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities(id)" `shouldRespondWith`
+            [json|[
+              {"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []},
+              {"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
+            ]|] { matchHeaders = [matchContentTypeJson] }
+
+        it "can do logic on the third level" $
+          get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities(id,grandchild_entities(id))"
+            `shouldRespondWith`
+              [json|[
+                {"id": 1, "child_entities": [
+                  { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]},
+                  { "id": 2, "grandchild_entities": []},
+                  { "id": 4, "grandchild_entities": []},
+                  { "id": 5, "grandchild_entities": []}
+                ]},
+                {"id": 2, "child_entities": [
+                  { "id": 3, "grandchild_entities": []},
+                  { "id": 6, "grandchild_entities": []}
+                ]},
+                {"id": 3, "child_entities": []},
+                {"id": 4, "child_entities": []}
+              ]|]
+
+      context "and/or params combined" $ do
+        it "can be nested inside the same expression" $
+          get "/entities?or=(and(name.eq.entity 2,id.eq.2),and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can be negated while nested" $
+          get "/entities?or=(not.and(name.eq.entity 2,id.eq.2),not.and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can be combined unnested" $
+          get "/entities?and=(id.eq.1,name.eq.entity 1)&or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
+
+      context "operators inside and/or" $ do
+        it "can handle eq and neq" $
+          get "/entities?and=(id.eq.1,id.neq.2))&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can handle lt and gt" $
+          get "/entities?or=(id.lt.2,id.gt.3)&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can handle lte and gte" $
+          get "/entities?or=(id.lte.2,id.gte.3)&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can handle like and ilike" $
+          get "/entities?or=(name.like.*1,name.ilike.*ENTITY 2)&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can handle in" $
+          get "/entities?or=(id.in.(1,2),id.in.(3,4))&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can handle is" $
+          get "/entities?and=(name.is.null,arr.is.null)&select=id" `shouldRespondWith`
+            [json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "can handle fts" $ do
+          get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith`
+            [json|[
+              {"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
+              {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
+              {"text_search_vector": "'art':4 'spass':5 'unmog':7"}
+            ]|] { matchHeaders = [matchContentTypeJson] }
+
+        when (actualPgVersion >= pgVersion112) $
+          it "can handle wfts (websearch_to_tsquery)" $
+            get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"
+            `shouldRespondWith`
+              [json|[
+                     {"text_search_vector": "'also':2 'fun':3 'possibl':8" },
+                     {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },
+                     {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
+                     {"text_search_vector": "'art':4 'spass':5 'unmog':7" }
+              ]|]
+              { matchHeaders = [matchContentTypeJson] }
+
+        it "can handle cs and cd" $
+          get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+
+        it "can handle range operators" $ do
+          get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=neq.[1,3]&select=id" `shouldRespondWith`
+            [json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=lt.[1,10]&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=gt.[8,11]&select=id" `shouldRespondWith`
+            [json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=lte.[1,3]&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=gte.[2,3]&select=id" `shouldRespondWith`
+            [json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=cs.[1,2]&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=cd.[1,6]&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=ov.[0,4]&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=sl.[9,10]&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=sr.[3,4]&select=id" `shouldRespondWith`
+            [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=nxr.[4,7]&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=nxl.[4,7]&select=id" `shouldRespondWith`
+            [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
+
+        it "can handle array operators" $ do
+          get "/entities?arr=eq.{1,2,3}&select=id" `shouldRespondWith`
+            [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=neq.{1,2}&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=lt.{2,3}&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=lt.{2,0}&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=gt.{1,1}&select=id" `shouldRespondWith`
+            [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=gt.{3}&select=id" `shouldRespondWith`
+            [json|[]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=lte.{2,1}&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=lte.{1,2,3}&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=lte.{1,2}&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=cs.{1,2}&select=id" `shouldRespondWith`
+            [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=cd.{1,2,6}&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=ov.{3}&select=id" `shouldRespondWith`
+            [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?arr=ov.{2,3}&select=id" `shouldRespondWith`
+            [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+
+        context "operators with not" $ do
+          it "eq, cs, like can be negated" $
+            get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
+              [json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
+          it "in, is, fts can be negated" $
+            get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith`
+              [json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
+          it "lt, gte, cd can be negated" $
+            get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
+              [json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
+          it "gt, lte, ilike can be negated" $
+            get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`
+              [json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
+
+      context "and/or params with quotes" $ do
+        it "eq can have quotes" $
+          get "/grandchild_entities?or=(name.eq.\"(grandchild,entity,4)\",name.eq.\"(grandchild,entity,5)\")&select=id" `shouldRespondWith`
+            [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "like and ilike can have quotes" $
+          get "/grandchild_entities?or=(name.like.\"*ity,4*\",name.ilike.\"*ITY,5)\")&select=id" `shouldRespondWith`
+            [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
+        it "in can have quotes" $
+          get "/grandchild_entities?or=(id.in.(\"1\",\"2\"),id.in.(\"3\",\"4\"))&select=id" `shouldRespondWith`
+            [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
+
+      it "allows whitespace" $
+        get "/entities?and=( and ( id.in.( 1, 2, 3 ) , id.eq.3 ) , or ( id.eq.2 , id.eq.3 ) )&select=id" `shouldRespondWith`
+          [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
+
+      context "multiple and/or conditions" $ do
+        it "cannot have zero conditions" $
+          get "/entities?or=()" `shouldRespondWith`
+            [json|{
+              "details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)",
+              "message": "\"failed to parse logic tree (())\" (line 1, column 4)"
+            }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+        it "can have a single condition" $ do
+          get "/entities?or=(id.eq.1)&select=id" `shouldRespondWith`
+            [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
+          get "/entities?and=(id.eq.1)&select=id" `shouldRespondWith`
+            [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
+        it "can have three conditions" $ do
+          get "/grandchild_entities?or=(id.eq.1, id.eq.2, id.eq.3)&select=id" `shouldRespondWith`
+            [json|[{"id":1}, {"id":2}, {"id":3}]|] { matchHeaders = [matchContentTypeJson] }
+          get "/grandchild_entities?and=(id.in.(1,2), id.in.(3,1), id.in.(1,4))&select=id" `shouldRespondWith`
+            [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
+        it "can have four conditions combining and/or" $ do
+          get "/grandchild_entities?or=( id.eq.1, id.eq.2, and(id.in.(1,3), id.in.(2,3)), id.eq.4 )&select=id" `shouldRespondWith`
+            [json|[{"id":1}, {"id":2}, {"id":3}, {"id":4}]|] { matchHeaders = [matchContentTypeJson] }
+          get "/grandchild_entities?and=( id.eq.1, not.or(id.eq.2, id.eq.3), id.in.(1,4), or(id.eq.1, id.eq.4) )&select=id" `shouldRespondWith`
+            [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
+
+    context "used with POST" $
+      it "includes related data with filters" $
+        request methodPost "/child_entities?select=id,entities(id)&entities.or=(id.eq.2,id.eq.3)&entities.order=id"
+            [("Prefer", "return=representation")]
+            [json|[
+              {"id":7,"name":"entity 4","parent_id":1},
+              {"id":8,"name":"entity 5","parent_id":2},
+              {"id":9,"name":"entity 6","parent_id":3}
+            ]|]
+          `shouldRespondWith`
+            [json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|]
+            { matchStatus = 201 }
+
+    context "used with PATCH" $
+      it "succeeds when using and/or params" $
+        request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
+          [("Prefer", "return=representation")]
+          [json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
+          [json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+    context "used with DELETE" $
+      it "succeeds when using and/or params" $
+        request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
+            [("Prefer", "return=representation")]
+            ""
+          `shouldRespondWith`
+            [json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|]
+
+    it "can query columns that begin with and/or reserved words" $
+      get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
+
+    it "fails when using IN without () and provides meaningful error message" $
+      get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`
+        [json|{
+          "details": "unexpected \"1\" expecting \"(\"",
+          "message": "\"failed to parse logic tree ((id.in.1,2,id.eq.3))\" (line 1, column 10)"
+        }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+
+    it "fails on malformed query params and provides meaningful error message" $ do
+      get "/entities?or=)(" `shouldRespondWith`
+        [json|{
+          "details": "unexpected \")\" expecting \"(\"",
+          "message": "\"failed to parse logic tree ()()\" (line 1, column 3)"
+        }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+      get "/entities?and=(ord(id.eq.1,id.eq.1),id.eq.2)" `shouldRespondWith`
+        [json|{
+          "details": "unexpected \"d\" expecting \"(\"",
+          "message": "\"failed to parse logic tree ((ord(id.eq.1,id.eq.1),id.eq.2))\" (line 1, column 7)"
+        }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+      get "/entities?or=(id.eq.1,not.xor(id.eq.2,id.eq.3))" `shouldRespondWith`
+        [json|{
+          "details": "unexpected \"x\" expecting logic operator (and, or)",
+          "message": "\"failed to parse logic tree ((id.eq.1,not.xor(id.eq.2,id.eq.3)))\" (line 1, column 16)"
+        }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
diff --git a/test/spec/Feature/AsymmetricJwtSpec.hs b/test/spec/Feature/AsymmetricJwtSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/AsymmetricJwtSpec.hs
@@ -0,0 +1,21 @@
+module Feature.AsymmetricJwtSpec where
+
+-- {{{ Imports
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+
+import Protolude
+import SpecHelper
+-- }}}
+
+spec :: SpecWith ((), Application)
+spec = describe "server started with asymmetric JWK" $
+
+  -- this test will stop working 9999999999s after the UNIX EPOCH
+  it "succeeds with jwt token signed with an asymmetric key" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlIjogInBvc3RncmVzdF90ZXN0X2F1dGhvciJ9Cg.CBOYWDvqgAR0YYnZnyDGTQi6AJLc2Pds6_eV3YuBG6I36mj_h05eLhkEKNEDA5ZteMzCiY83P60rC_xtxVd7B6vo3BeF5uoanPS3rrbuHzKPwzsrgrD_CqvEuJ4n7Q9epkQiLsNkcexneENZDRqFjbwZx3DrXiCWwlK3Ytr5NAIGxmy0od-0xNpb2U1nXQyO_Q3mumWFViRt4tmFn_3goDHNKG3Ha_AzImfUNvHnWL78kAc4rbn15vLtWXD8PwtSnZaB4lY4V6RfsaW937srQsmRetvytM1i_bHBnjkjQLAqGbXPyItjtlXPs0uGNBadE8-wgkLtfmSCC4v2DjUthw"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 200
diff --git a/test/spec/Feature/AudienceJwtSecretSpec.hs b/test/spec/Feature/AudienceJwtSecretSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/AudienceJwtSecretSpec.hs
@@ -0,0 +1,47 @@
+module Feature.AudienceJwtSecretSpec where
+
+-- {{{ Imports
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+
+import Protolude  hiding (get)
+import SpecHelper
+-- }}}
+
+spec :: SpecWith ((), Application)
+spec = describe "test handling of aud claims in JWT" $ do
+
+  -- this test will stop working 9999999999s after the UNIX EPOCH
+  it "succeeds with jwt token containing with an audience claim" $ do
+    {-  This is the decoded contents of authHeaderJWT
+
+        {
+          "exp": 9999999999,
+          "role": "postgrest_test_author",
+          "id": "jdoe",
+          "aud": "youraudience"
+        }
+
+    -}
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 200
+
+  it "succeeds with jwt token that does not contain an audience claim" $ do
+    {- This is the decoded contents of authHeaderJWT
+
+        {
+          "exp": 9999999999,
+          "role": "postgrest_test_author",
+          "id": "jdoe"
+        }
+    -}
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 200
+
+  it "requests without JWT token should work" $
+    get "/has_count_column" `shouldRespondWith` 200
diff --git a/test/spec/Feature/AuthSpec.hs b/test/spec/Feature/AuthSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/AuthSpec.hs
@@ -0,0 +1,179 @@
+module Feature.AuthSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import PostgREST.Config.PgVersion (PgVersion, pgVersion112)
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion = describe "authorization" $ do
+  let single = ("Accept","application/vnd.pgrst.object+json")
+
+  it "denies access to tables that anonymous does not own" $
+    get "/authors_only" `shouldRespondWith` (
+        if actualPgVersion >= pgVersion112 then
+        [json| {
+          "hint":null,
+          "details":null,
+          "code":"42501",
+          "message":"permission denied for table authors_only"} |]
+           else
+        [json| {
+          "hint":null,
+          "details":null,
+          "code":"42501",
+          "message":"permission denied for relation authors_only"} |]
+                                            )
+      { matchStatus = 401
+      , matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
+      }
+
+  it "denies access to tables that postgrest_test_author does not own" $
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
+    request methodGet "/private_table" [auth] ""
+      `shouldRespondWith` (
+        if actualPgVersion >= pgVersion112 then
+        [json| {
+          "hint":null,
+          "details":null,
+          "code":"42501",
+          "message":"permission denied for table private_table"} |]
+           else
+        [json| {
+          "hint":null,
+          "details":null,
+          "code":"42501",
+          "message":"permission denied for relation private_table"} |]
+                          )
+      { matchStatus = 403
+      , matchHeaders = []
+      }
+
+  it "denies execution on functions that anonymous does not own" $
+    post "/rpc/privileged_hello" [json|{"name": "anonymous"}|] `shouldRespondWith` 401
+
+  it "allows execution on a function that postgrest_test_author owns" $
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
+    request methodPost "/rpc/privileged_hello" [auth] [json|{"name": "jdoe"}|]
+      `shouldRespondWith` [json|"Privileged hello to jdoe"|]
+      { matchStatus = 200
+      , matchHeaders = [matchContentTypeJson]
+      }
+
+  it "returns jwt functions as jwt tokens" $
+    request methodPost "/rpc/login" [single]
+      [json| { "id": "jdoe", "pass": "1234" } |]
+      `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.KO-0PGp_rU-utcDBP6qwdd-Th2Fk-ICVt01I7QtTDWs"} |]
+        { matchStatus = 200
+        , matchHeaders = [matchContentTypeSingular]
+        }
+
+  it "sql functions can encode custom and standard claims" $
+    request methodPost  "/rpc/jwt_test" [single] "{}"
+      `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.G2REtPnOQMUrVRDA9OnkPJTd8R0tf4wdYOlauh1E2Ek"} |]
+        { matchStatus = 200
+        , matchHeaders = [matchContentTypeSingular]
+        }
+
+  it "sql functions can read custom and standard claims variables" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6OTk5OTk5OTk5OSwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWF0IjoxMzAwODE5MzgwfQ.V5fEpXfpb7feqwVqlcDleFdKu86bdwU2cBRT4fcMhXg"
+    request methodPost "/rpc/reveal_big_jwt" [auth] "{}"
+      `shouldRespondWith` [json|[{"iss":"joe","sub":"fun","exp":9999999999,"nbf":1300819380,"iat":1300819380,"jti":"foo","http://postgrest.com/foo":true}]|]
+
+  it "allows users with permissions to see their tables" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 200
+
+  it "works with tokens which have extra fields" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIiwia2V5MSI6InZhbHVlMSIsImtleTIiOiJ2YWx1ZTIiLCJrZXkzIjoidmFsdWUzIiwiYSI6MSwiYiI6MiwiYyI6M30.b0eglDKYEmGi-hCvD-ddSqFl7vnDO5qkUaviaHXm3es"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 200
+
+  -- this test will stop working 9999999999s after the UNIX EPOCH
+  it "succeeds with an unexpired token" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 200
+
+  it "fails with an expired token" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.f8__E6VQwYcDqwHmr9PG03uaZn8Zh1b0vbJ9DYS0AdM"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` [json| {"message":"JWT expired"} |]
+        { matchStatus = 401
+        , matchHeaders = [
+            "WWW-Authenticate" <:>
+            "Bearer error=\"invalid_token\", error_description=\"JWT expired\""
+          ]
+        }
+
+  it "hides tables from users with invalid JWT" $ do
+    let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"} |]
+        { matchStatus = 401
+        , matchHeaders = [
+            "WWW-Authenticate" <:>
+            "Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""
+          ]
+        }
+
+  it "should fail when jwt contains no claims" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.CUIP5V9thWsGGFsFyGijSZf1fJMfarLHI9CEJL-TGNk"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 401
+
+  it "hides tables from users with JWT that contain no claims about role" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.RVlZDaSyKbFPvxUf3V_NQXybfRB4dlBIkAUQXVXLUAI"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 401
+
+  it "recovers after 401 error with logged in user" $ do
+    _ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
+    _ <- request methodPost "/rpc/problem" [auth] ""
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 200
+
+  describe "custom pre-request proc acting on id claim" $ do
+
+    it "able to switch to postgrest_test_author role (id=1)" $
+      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.gKw7qI50i9hMrSJW8BlTpdMEVmMXJYxlAqueGqpa_mE" in
+      request methodPost "/rpc/get_current_user" [auth]
+        [json| {} |]
+         `shouldRespondWith` [json|"postgrest_test_author"|]
+          { matchStatus = 200
+          , matchHeaders = []
+          }
+
+    it "able to switch to postgrest_test_default_role (id=2)" $
+      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.nwzjMI0YLvVGJQTeoCPEBsK983b__gxdpLXisBNaO2A" in
+      request methodPost "/rpc/get_current_user" [auth]
+        [json| {} |]
+         `shouldRespondWith` [json|"postgrest_test_default_role"|]
+          { matchStatus = 200
+          , matchHeaders = []
+          }
+
+    it "raises error (id=3)" $
+      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.OGxEJAf60NKZiTn-tIb2jy4rqKs_ZruLGWZ40TjrJsM" in
+      request methodPost "/rpc/get_current_user" [auth]
+        [json| {} |]
+         `shouldRespondWith` [json|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]
+          { matchStatus = 400
+          , matchHeaders = []
+          }
+
+  it "allows 'Bearer' and 'bearer' as authentication schemes" $ do
+    let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
+    request methodGet "/authors_only" [authHeader "Bearer" token] ""
+      `shouldRespondWith` 200
+    request methodGet "/authors_only" [authHeader "bearer" token] ""
+      `shouldRespondWith` 200
diff --git a/test/spec/Feature/BinaryJwtSecretSpec.hs b/test/spec/Feature/BinaryJwtSecretSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/BinaryJwtSecretSpec.hs
@@ -0,0 +1,21 @@
+module Feature.BinaryJwtSecretSpec where
+
+-- {{{ Imports
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+
+import Protolude
+import SpecHelper
+-- }}}
+
+spec :: SpecWith ((), Application)
+spec = describe "server started with binary JWT secret" $
+
+  -- this test will stop working 9999999999s after the UNIX EPOCH
+  it "succeeds with jwt token encoded with a binary secret" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith` 200
diff --git a/test/spec/Feature/ConcurrentSpec.hs b/test/spec/Feature/ConcurrentSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/ConcurrentSpec.hs
@@ -0,0 +1,52 @@
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE TypeFamilies          #-}
+{-# LANGUAGE UndecidableInstances  #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+module Feature.ConcurrentSpec where
+
+import Control.Concurrent.Async (mapConcurrently)
+import Network.Wai              (Application)
+
+import Control.Monad.Base
+import Control.Monad.Trans.Control
+
+import Network.Wai.Test        (Session)
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.Internal
+import Test.Hspec.Wai.JSON
+
+import Protolude hiding (get)
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "Querying in parallel" $
+    it "should not raise 'transaction in progress' error" $
+      raceTest 10 $
+        get "/fakefake"
+          `shouldRespondWith` [json|
+              { "hint": null,
+                "details":null,
+                "code":"42P01",
+                "message":"relation \"test.fakefake\" does not exist"
+              } |]
+          { matchStatus  = 404
+          , matchHeaders = []
+          }
+
+raceTest :: Int -> WaiExpectation st -> WaiExpectation st
+raceTest times = liftBaseDiscard go
+ where
+  go test = void $ mapConcurrently (const test) [1..times]
+
+instance MonadBaseControl IO (WaiSession st) where
+  type StM (WaiSession st) a = StM Session a
+  liftBaseWith f = WaiSession $
+    liftBaseWith $ \runInBase ->
+      f $ \k -> runInBase (unWaiSession k)
+  restoreM = WaiSession . restoreM
+  {-# INLINE liftBaseWith #-}
+  {-# INLINE restoreM #-}
+
+instance MonadBase IO (WaiSession st) where
+  liftBase = liftIO
diff --git a/test/spec/Feature/CorsSpec.hs b/test/spec/Feature/CorsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/CorsSpec.hs
@@ -0,0 +1,56 @@
+module Feature.CorsSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+
+import Protolude
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "CORS" $ do
+    it "replies naively and permissively to preflight request" $
+      request methodOptions "/"
+          [ ("Accept", "*/*")
+          , ("Origin", "http://example.com")
+          , ("Access-Control-Request-Method", "POST")
+          , ("Access-Control-Request-Headers", "Foo,Bar") ]
+          ""
+        `shouldRespondWith`
+          ""
+          { matchHeaders = [ "Access-Control-Allow-Origin" <:> "http://example.com"
+                           , "Access-Control-Allow-Credentials" <:> "true"
+                           , "Access-Control-Allow-Methods" <:> "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"
+                           , "Access-Control-Allow-Headers" <:> "Authorization, Foo, Bar, Accept, Accept-Language, Content-Language"
+                           , "Access-Control-Max-Age" <:> "86400" ]
+          }
+
+    it "exposes necesssary response headers to regular request" $
+      request methodGet "/items"
+          [("Origin", "http://example.com")]
+          ""
+        `shouldRespondWith`
+          ResponseMatcher
+          { matchStatus = 200
+          , matchBody = MatchBody (\_ _ -> Nothing) -- match any body
+          , matchHeaders = [ "Access-Control-Expose-Headers" <:>
+                             "Content-Encoding, Content-Location, Content-Range, Content-Type, \
+                             \Date, Location, Server, Transfer-Encoding, Range-Unit"]
+          }
+
+    it "allows INFO body through even with CORS request headers present to postflight request" $
+      request methodOptions "/items"
+          [ ("Host", "localhost:3000")
+          , ("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0")
+          , ("Origin", "http://localhost:8000")
+          , ("Accept", "text/csv, */*; q=0.01")
+          , ("Accept-Language", "en-US,en;q=0.5")
+          , ("Accept-Encoding", "gzip, deflate")
+          , ("Referer", "http://localhost:8000/")
+          , ("Connection", "keep-alive") ]
+          ""
+        `shouldRespondWith`
+          ""
+          { matchHeaders = [ "Access-Control-Allow-Origin" <:> "*" ] }
diff --git a/test/spec/Feature/DeleteSpec.hs b/test/spec/Feature/DeleteSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/DeleteSpec.hs
@@ -0,0 +1,92 @@
+module Feature.DeleteSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude hiding (get)
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "Deleting" $ do
+    context "existing record" $ do
+      it "succeeds with 204 and deletion count" $
+        request methodDelete "/items?id=eq.1" [] ""
+          `shouldRespondWith` ""
+          { matchStatus  = 204
+          , matchHeaders = ["Content-Range" <:> "*/*"]
+          }
+
+      it "returns the deleted item and count if requested" $
+        request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""
+          `shouldRespondWith` [json|[{"id":2}]|]
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "*/1"]
+          }
+
+      it "ignores ?select= when return not set or return=minimal" $ do
+        request methodDelete "/items?id=eq.3&select=id" [] ""
+          `shouldRespondWith` ""
+          { matchStatus  = 204
+          , matchHeaders = ["Content-Range" <:> "*/*"]
+          }
+        request methodDelete "/items?id=eq.3&select=id" [("Prefer", "return=minimal")] ""
+          `shouldRespondWith` ""
+          { matchStatus  = 204
+          , matchHeaders = ["Content-Range" <:> "*/*"]
+          }
+
+      it "returns the deleted item and shapes the response" $
+        request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
+          `shouldRespondWith` [json|[{"id":2,"name":"Two"}]|]
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "*/*"]
+          }
+
+      it "can rename and cast the selected columns" $
+        request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
+          `shouldRespondWith` [json|[{"ciId":"3","ciName":"Three"}]|]
+
+      it "can embed (parent) entities" $
+        request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] ""
+          `shouldRespondWith` [json|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "*/*"]
+          }
+
+    context "known route, no records matched" $
+      it "includes [] body if return=rep" $
+        request methodDelete "/items?id=eq.101"
+          [("Prefer", "return=representation")] ""
+          `shouldRespondWith` "[]"
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "*/*"]
+          }
+
+    context "totally unknown route" $
+      it "fails with 404" $
+        request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
+
+    context "table with limited privileges" $ do
+      it "fails deleting the row when return=representation and selecting all the columns" $
+        request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty
+            `shouldRespondWith` 401
+
+      it "succeeds deleting the row when return=representation and selecting only the privileged columns" $
+        request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]
+          [json| { "password": "passxyz" } |]
+            `shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]
+            { matchStatus  = 200
+            , matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+      it "suceeds deleting the row with no explicit select when using return=minimal" $
+        request methodDelete "/app_users?id=eq.2" [("Prefer", "return=minimal")] mempty
+            `shouldRespondWith` 204
+
+      it "suceeds deleting the row with no explicit select by default" $
+        request methodDelete "/app_users?id=eq.3" [] mempty
+            `shouldRespondWith` 204
diff --git a/test/spec/Feature/DisabledOpenApiSpec.hs b/test/spec/Feature/DisabledOpenApiSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/DisabledOpenApiSpec.hs
@@ -0,0 +1,20 @@
+module Feature.DisabledOpenApiSpec where
+
+import Network.HTTP.Types
+import Network.Wai        (Application)
+
+import Test.Hspec     hiding (pendingWith)
+import Test.Hspec.Wai
+
+import Protolude
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "Disabled OpenApi" $ do
+    it "does not accept application/openapi+json and responds with 415" $
+      request methodGet "/"
+        [("Accept","application/openapi+json")] "" `shouldRespondWith` 415
+
+    it "accepts application/json and responds with 404" $
+      request methodGet "/"
+        [("Accept","application/json")] "" `shouldRespondWith` 404
diff --git a/test/spec/Feature/EmbedDisambiguationSpec.hs b/test/spec/Feature/EmbedDisambiguationSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/EmbedDisambiguationSpec.hs
@@ -0,0 +1,464 @@
+module Feature.EmbedDisambiguationSpec where
+
+import Network.Wai (Application)
+
+import Test.Hspec          hiding (pendingWith)
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "resource embedding disambiguation" $ do
+    context "ambiguous requests that give 300 Multiple Choices" $ do
+      it "errs when there's a table and view that point to the same fk" $
+        get "/message?select=id,body,sender(name,sent)" `shouldRespondWith`
+          [json|
+            {
+              "details": [
+                {
+                    "cardinality": "many-to-one",
+                    "relationship": "message_sender_fkey using message(sender) and person(id)",
+                    "embedding": "message with person"
+                },
+                {
+                    "cardinality": "many-to-one",
+                    "relationship": "message_sender_fkey using message(sender) and person_detail(id)",
+                    "embedding": "message with person_detail"
+                }
+              ],
+              "hint": "Try changing 'sender' to one of the following: 'person!message_sender_fkey', 'person_detail!message_sender_fkey'. Find the desired relationship in the 'details' key.",
+              "message": "Could not embed because more than one relationship was found for 'message' and 'sender'"
+            }
+          |]
+          { matchStatus  = 300
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+      it "errs when there's a table and view that point to the same fk (composite pk)" $
+        get "/activities?select=fst_shift(*)" `shouldRespondWith`
+          [json|
+            {
+              "details": [
+                {
+                    "cardinality": "one-to-many",
+                    "relationship": "fst_shift using activities(id, schedule_id) and unit_workdays(fst_shift_activity_id, fst_shift_schedule_id)",
+                    "embedding": "activities with unit_workdays"
+                },
+                {
+                    "cardinality": "one-to-many",
+                    "relationship": "fst_shift using activities(id, schedule_id) and unit_workdays_fst_shift(fst_shift_activity_id, fst_shift_schedule_id)",
+                    "embedding": "activities with unit_workdays_fst_shift"
+                }
+              ],
+              "hint": "Try changing 'fst_shift' to one of the following: 'unit_workdays!fst_shift', 'unit_workdays_fst_shift!fst_shift'. Find the desired relationship in the 'details' key.",
+              "message": "Could not embed because more than one relationship was found for 'activities' and 'fst_shift'"
+            }
+          |]
+          { matchStatus  = 300
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+      it "errs when there are o2m and m2m cardinalities to the target table" $
+        get "/sites?select=*,big_projects(*)" `shouldRespondWith`
+          [json|
+            {
+              "details": [
+                {
+                  "cardinality": "many-to-one",
+                  "relationship": "main_project using sites(main_project_id) and big_projects(big_project_id)",
+                  "embedding": "sites with big_projects"
+                },
+                {
+                  "cardinality": "many-to-many",
+                  "relationship": "jobs using jobs_site_id_fkey(site_id) and jobs_big_project_id_fkey(big_project_id)",
+                  "embedding": "sites with big_projects"
+                },
+                {
+                  "cardinality": "many-to-many",
+                  "relationship": "main_jobs using jobs_site_id_fkey(site_id) and jobs_big_project_id_fkey(big_project_id)",
+                  "embedding": "sites with big_projects"
+                }
+              ],
+              "hint": "Try changing 'big_projects' to one of the following: 'big_projects!main_project', 'big_projects!jobs', 'big_projects!main_jobs'. Find the desired relationship in the 'details' key.",
+              "message": "Could not embed because more than one relationship was found for 'sites' and 'big_projects'"
+            }
+          |]
+          { matchStatus  = 300
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+      it "errs on an ambiguous embed that has a circular reference" $
+        get "/agents?select=*,departments(*)" `shouldRespondWith`
+          [json|
+            {
+              "details": [
+                {
+                    "cardinality": "many-to-one",
+                    "relationship": "agents_department_id_fkey using agents(department_id) and departments(id)",
+                    "embedding": "agents with departments"
+                },
+                {
+                    "cardinality": "one-to-many",
+                    "relationship": "departments_head_id_fkey using agents(id) and departments(head_id)",
+                    "embedding": "agents with departments"
+                }
+              ],
+              "hint": "Try changing 'departments' to one of the following: 'departments!agents_department_id_fkey', 'departments!departments_head_id_fkey'. Find the desired relationship in the 'details' key.",
+              "message": "Could not embed because more than one relationship was found for 'agents' and 'departments'"
+            }
+           |]
+          { matchStatus  = 300
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+      it "errs when there are more than two fks on a junction table(currently impossible to disambiguate, only choice is to split the table)" $
+        -- We have 4 possibilities for doing the junction JOIN here.
+        -- This could be solved by specifying two additional fks, like whatev_projects!fk1!fk2(*)
+        -- If the need arises this capability can be added later without causing a breaking change
+        get "/whatev_sites?select=*,whatev_projects(*)" `shouldRespondWith`
+          [json|
+            {
+              "details": [
+                {
+                  "cardinality": "many-to-many",
+                  "relationship": "whatev_jobs using whatev_jobs_site_id_1_fkey(site_id_1) and whatev_jobs_project_id_1_fkey(project_id_1)",
+                  "embedding": "whatev_sites with whatev_projects"
+                },
+                {
+                  "cardinality": "many-to-many",
+                  "relationship": "whatev_jobs using whatev_jobs_site_id_1_fkey(site_id_1) and whatev_jobs_project_id_2_fkey(project_id_2)",
+                  "embedding": "whatev_sites with whatev_projects"
+                },
+                {
+                  "cardinality": "many-to-many",
+                  "relationship": "whatev_jobs using whatev_jobs_site_id_2_fkey(site_id_2) and whatev_jobs_project_id_1_fkey(project_id_1)",
+                  "embedding": "whatev_sites with whatev_projects"
+                },
+                {
+                  "cardinality": "many-to-many",
+                  "relationship": "whatev_jobs using whatev_jobs_site_id_2_fkey(site_id_2) and whatev_jobs_project_id_2_fkey(project_id_2)",
+                  "embedding": "whatev_sites with whatev_projects"
+                }
+              ],
+              "hint": "Try changing 'whatev_projects' to one of the following: 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs'. Find the desired relationship in the 'details' key.",
+              "message": "Could not embed because more than one relationship was found for 'whatev_sites' and 'whatev_projects'"
+            }
+          |]
+          { matchStatus  = 300
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+    context "disambiguating requests with embed hints" $ do
+
+      context "using FK to specify the relationship" $ do
+        it "can embed by FK name" $
+          get "/projects?id=in.(1,3)&select=id,name,client(id,name)" `shouldRespondWith`
+            [json|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client":{"id":2,"name":"Apple"}}]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can embed by FK name and select the FK column at the same time" $
+          get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`
+            [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can embed parent with view!fk and grandparent by using fk" $
+          get "/tasks?id=eq.1&select=id,name,projects_view!project(id,name,client(id,name))" `shouldRespondWith`
+            [json|[{"id":1,"name":"Design w7","projects_view":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
+
+        it "can embed by using a composite FK name" $
+          get "/unit_workdays?select=unit_id,day,fst_shift(car_id,schedule(name)),snd_shift(camera_id,schedule(name))" `shouldRespondWith`
+            [json| [
+              {
+                "day": "2019-12-02",
+                "fst_shift": {
+                    "car_id": "CAR-349",
+                    "schedule": {
+                        "name": "morning"
+                    }
+                },
+                "snd_shift": {
+                    "camera_id": "CAM-123",
+                    "schedule": {
+                        "name": "night"
+                    }
+                },
+                "unit_id": 1
+              }
+            ] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "embeds by using two fks pointing to the same table" $
+          get "/orders?id=eq.1&select=id, name, billing(address), shipping(address)" `shouldRespondWith`
+            [json|[{"id":1,"name":"order 1","billing":{"address": "address 1"},"shipping":{"address": "address 2"}}]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "fails if the fk is not known" $
+          get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`
+            [json|{
+              "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
+              "message":"Could not find a relationship between message and person in the schema cache"}|]
+            { matchStatus = 400
+            , matchHeaders = [matchContentTypeJson] }
+
+        it "can request a parent with fk" $
+          get "/comments?select=content,user(name)" `shouldRespondWith`
+            [json|[ { "content": "Needs to be delivered ASAP", "user": { "name": "Angela Martin" } } ]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can request two parents with fks" $
+          get "/articleStars?select=createdAt,article(id),user(name)&limit=1"
+            `shouldRespondWith`
+              [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"id": 1},"user":{"name": "Angela Martin"}}]|]
+
+        it "can specify a view!fk" $
+          get "/message?select=id,body,sender:person_detail!message_sender_fkey(name,sent),recipient:person_detail!message_recipient_fkey(name,received)&id=lt.4" `shouldRespondWith`
+            [json|
+              [{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
+               {"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
+               {"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can specify a table!fk hint and request children 2 levels" $
+          get "/clients?id=eq.1&select=id,projects:projects!client(id,tasks(id))" `shouldRespondWith`
+            [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can disambiguate with the fk in case of an o2m and m2m relationship to the same table" $
+          get "/sites?select=name,main_project(name)&site_id=eq.1" `shouldRespondWith`
+            [json| [ { "name": "site 1", "main_project": { "name": "big project 1" } } ] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+      context "using the column name of the FK to specify the relationship" $ do
+        it "can embed by column" $
+          get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`
+            [json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can embed by column and select the column at the same time, if aliased" $
+          get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`
+            [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can embed parent by using view!column and grandparent by using the column" $
+          get "/tasks?id=eq.1&select=id,name,project:projects_view!project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
+            [json|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
+
+        it "can specify table!column" $
+          get "/message?select=id,body,sender:person!sender(name),recipient:person!recipient(name)&id=lt.4" `shouldRespondWith`
+            [json|
+              [{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
+               {"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
+               {"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "will embed using a column that has uppercase chars" $
+          get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
+            [json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "embeds by using two columns pointing to the same table" $
+          get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`
+            [json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can disambiguate with the column in case of an o2m and m2m relationship to the same table" $
+          get "/sites?select=name,main_project_id(name)&site_id=eq.1" `shouldRespondWith`
+            [json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+      context "using the junction to disambiguate the request" $
+        it "can specify the junction of an m2m relationship" $ do
+          get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
+            [json|
+              [
+                {
+                  "big_projects": [
+                    {
+                      "name": "big project 1"
+                    }
+                  ],
+                  "main_project_id": 1,
+                  "name": "site 1",
+                  "site_id": 1
+                },
+                {
+                  "big_projects": [
+                    {
+                      "name": "big project 1"
+                    },
+                    {
+                      "name": "big project 2"
+                    }
+                  ],
+                  "main_project_id": null,
+                  "name": "site 2",
+                  "site_id": 2
+                }
+              ]
+            |]
+          get "/sites?select=*,big_projects!main_jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
+            [json|
+              [
+                {
+                  "big_projects": [
+                    {
+                      "name": "big project 1"
+                    }
+                  ],
+                  "main_project_id": 1,
+                  "name": "site 1",
+                  "site_id": 1
+                },
+                {
+                  "big_projects": [],
+                  "main_project_id": null,
+                  "name": "site 2",
+                  "site_id": 2
+                }
+              ]
+            |]
+            { matchHeaders = [matchContentTypeJson] }
+
+      context "using a FK column and a FK to specify the relationship" $
+        it "embeds by using a column and a fk pointing to the same table" $
+          get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping(id)" `shouldRespondWith`
+            [json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping":{"id":2}}]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+    context "tables with self reference foreign keys" $ do
+      context "one self reference foreign key" $ do
+        it "embeds parents recursively" $
+          get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`
+            [json|[
+              { "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },
+              { "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }
+            ]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "embeds children recursively" $
+          get "/family_tree?id=eq.1&select=id,name, children:family_tree!parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
+            [json|[{
+              "id": "1", "name": "Parental Unit", "children": [
+                { "id": "2", "name": "Kid One", "children": [ { "id": "4", "name": "Grandkid One" } ] },
+                { "id": "3", "name": "Kid Two", "children": [ { "id": "5", "name": "Grandkid Two" } ] }
+              ]
+            }]|] { matchHeaders = [matchContentTypeJson] }
+
+        it "embeds parent and then embeds children" $
+          get "/family_tree?id=eq.2&select=id,name,parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
+            [json|[{
+              "id": "2", "name": "Kid One", "parent": {
+                "id": "1", "name": "Parental Unit", "children": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
+              }
+            }]|] { matchHeaders = [matchContentTypeJson] }
+
+      context "two self reference foreign keys" $ do
+        it "embeds parents" $
+          get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
+            [json|[{
+              "id": 3, "name": "Acme",
+              "referee": {
+                "id": 1,
+                "name": "Referee Org"
+              },
+              "auditor": {
+                "id": 2,
+                "name": "Auditor Org"
+              }
+            }]|] { matchHeaders = [matchContentTypeJson] }
+
+        it "embeds children" $ do
+          get "/organizations?select=id,name,refereeds:organizations!referee(id,name)&id=eq.1" `shouldRespondWith`
+            [json|[{
+              "id": 1, "name": "Referee Org",
+              "refereeds": [
+                {
+                  "id": 3,
+                  "name": "Acme"
+                },
+                {
+                  "id": 4,
+                  "name": "Umbrella"
+                }
+              ]
+            }]|] { matchHeaders = [matchContentTypeJson] }
+          get "/organizations?select=id,name,auditees:organizations!auditor(id,name)&id=eq.2" `shouldRespondWith`
+            [json|[{
+              "id": 2, "name": "Auditor Org",
+              "auditees": [
+                {
+                  "id": 3,
+                  "name": "Acme"
+                },
+                {
+                  "id": 4,
+                  "name": "Umbrella"
+                }
+              ]
+            }]|] { matchHeaders = [matchContentTypeJson] }
+
+        it "embeds other relations(manager) besides the self reference" $ do
+          get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`
+            [json|[{
+              "name":"Cyberdyne",
+              "manager":{"name":"Cyberdyne Manager"},
+              "referee":{
+                "name":"Acme",
+                "manager":{"name":"Acme Manager"},
+                "auditor":{
+                  "name":"Auditor Org",
+                  "manager":{"name":"Auditor Manager"}}},
+              "auditor":{
+                "name":"Umbrella",
+                "manager":{"name":"Umbrella Manager"},
+                "referee":{
+                  "name":"Referee Org",
+                  "manager":{"name":"Referee Manager"}}}
+            }]|] { matchHeaders = [matchContentTypeJson] }
+
+          get "/organizations?select=name,manager(name),auditees:organizations!auditor(name,manager(name),refereeds:organizations!referee(name,manager(name)))&id=eq.2" `shouldRespondWith`
+            [json|[{
+              "name":"Auditor Org",
+              "manager":{"name":"Auditor Manager"},
+              "auditees":[
+                {"name":"Acme",
+                 "manager":{"name":"Acme Manager"},
+                 "refereeds":[
+                   {"name":"Cyberdyne",
+                    "manager":{"name":"Cyberdyne Manager"}},
+                   {"name":"Oscorp",
+                    "manager":{"name":"Oscorp Manager"}}]},
+                {"name":"Umbrella",
+                 "manager":{"name":"Umbrella Manager"},
+                 "refereeds":[]}]
+            }]|] { matchHeaders = [matchContentTypeJson] }
+
+    context "m2m embed when there's a junction in an internal schema" $ do
+      -- https://github.com/PostgREST/postgrest/issues/1736
+      it "works with no ambiguity when there's an exposed view of the junction" $ do
+        get "/screens?select=labels(name)" `shouldRespondWith`
+          [json|[{"labels":[{"name":"fruit"}]}, {"labels":[{"name":"vehicles"}]}, {"labels":[{"name":"vehicles"}, {"name":"fruit"}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/actors?select=*,films(*)" `shouldRespondWith`
+          [json|[ {"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]},
+                  {"id":2,"name":"mary","films":[{"id":2001,"title":"odyssée de l'espace"}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+      it "doesn't work if the junction is only internal" $
+        get "/end_1?select=end_2(*)" `shouldRespondWith`
+          [json|{
+            "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
+            "message":"Could not find a relationship between end_1 and end_2 in the schema cache"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson] }
+      it "shouldn't try to embed if the private junction has an exposed homonym" $
+        -- ensures the "invalid reference to FROM-clause entry for table "rollen" error doesn't happen.
+        -- Ref: https://github.com/PostgREST/postgrest/issues/1587#issuecomment-734995669
+        get "/schauspieler?select=filme(*)" `shouldRespondWith`
+          [json|{
+            "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
+            "message":"Could not find a relationship between schauspieler and filme in the schema cache"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson] }
diff --git a/test/spec/Feature/EmbedInnerJoinSpec.hs b/test/spec/Feature/EmbedInnerJoinSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/EmbedInnerJoinSpec.hs
@@ -0,0 +1,356 @@
+module Feature.EmbedInnerJoinSpec where
+
+import Network.HTTP.Types
+import Network.Wai        (Application)
+
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "Embedding with an inner join" $ do
+    context "many-to-one relationships" $ do
+      it "ignores null embeddings while the default left join doesn't" $ do
+        get "/projects?select=id,clients!inner(id)" `shouldRespondWith`
+          [json|[
+            {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
+            {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/projects?select=id,clients!left(id)" `shouldRespondWith`
+          [json|[
+            {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
+            {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},
+            {"id":5,"clients":null}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/projects?select=id,clients!inner(id)" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-3/4" ]
+          }
+
+      it "filters source tables when the embedded table is filtered" $ do
+        get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
+          [json|[
+            {"id":1,"clients":{"id":1}},
+            {"id":2,"clients":{"id":1}}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/projects?select=id,clients!inner(id)&clients.id=eq.2" `shouldRespondWith`
+          [json|[
+            {"id":3,"clients":{"id":2}},
+            {"id":4,"clients":{"id":2}}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith`
+          [json|[]|]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/projects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-1/2" ]
+          }
+
+      it "filters source tables when a two levels below embedded table is filtered" $ do
+        get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" `shouldRespondWith`
+          [json|[
+            {"id":1,"projects":{"id":1,"clients":{"id":1}}},
+            {"id":2,"projects":{"id":1,"clients":{"id":1}}},
+            {"id":3,"projects":{"id":2,"clients":{"id":1}}},
+            {"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
+          [json|[
+            {"id":5,"projects":{"id":3,"clients":{"id":2}}},
+            {"id":6,"projects":{"id":3,"clients":{"id":2}}},
+            {"id":7,"projects":{"id":4,"clients":{"id":2}}},
+            {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-3/4" ]
+          }
+
+      it "only affects the source table rows if his direct embedding is an inner join" $ do
+        get "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
+          [json|[
+            {"id":1,"projects":null},
+            {"id":2,"projects":null},
+            {"id":3,"projects":null},
+            {"id":4,"projects":null},
+            {"id":5,"projects":{"id":3,"clients":{"id":2}}},
+            {"id":6,"projects":{"id":3,"clients":{"id":2}}},
+            {"id":7,"projects":{"id":4,"clients":{"id":2}}},
+            {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-7/8" ]
+          }
+
+      it "works with views" $ do
+        get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith`
+          [json| [{"title":"1984","authors":{"name":"George Orwell"}}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-0/1" ]
+          }
+
+    context "one-to-many relationships" $ do
+      it "ignores empty array embeddings while the default left join doesn't" $ do
+        get "/entities?select=id,child_entities!inner(id)" `shouldRespondWith`
+          [json|[
+            {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
+            {"id":2,"child_entities":[{"id":3}, {"id":6}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/entities?select=id,child_entities!left(id)" `shouldRespondWith`
+          [json| [
+            {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
+            {"id":2,"child_entities":[{"id":3}, {"id":6}]},
+            {"id":3,"child_entities":[]},
+            {"id":4,"child_entities":[]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/entities?select=id,child_entities!inner(id)" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-1/2" ]
+          }
+
+      it "filters source tables when the embedded table is filtered" $ do
+        get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" `shouldRespondWith`
+          [json|[{"id":1,"child_entities":[{"id":1}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.3" `shouldRespondWith`
+          [json|[{"id":2,"child_entities":[{"id":3}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith`
+          [json|[]|]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-0/1" ]
+          }
+
+      it "filters source tables when a two levels below embedded table is filtered" $ do
+        get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)"
+          `shouldRespondWith`
+          [json|[
+            {
+              "id": 1,
+              "child_entities": [
+                { "id": 1, "grandchild_entities": [ { "id": 1 } ] },
+                { "id": 2, "grandchild_entities": [ { "id": 5 } ] }]
+            }
+          ]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
+          [json|[
+            {
+              "id": 1,
+              "child_entities": [
+                { "id": 1, "grandchild_entities": [ { "id": 2 } ] } ]
+            }
+          ]|]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-0/1" ]
+          }
+
+      it "only affects the source table rows if his direct embedding is an inner join" $ do
+        get "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
+          [json|[
+            {
+              "id": 1,
+              "child_entities": [
+                { "id": 1, "grandchild_entities": [ { "id": 2 } ] },
+                { "id": 2, "grandchild_entities": [] },
+                { "id": 4, "grandchild_entities": [] },
+                { "id": 5, "grandchild_entities": [] } ]
+            },
+            {
+              "id": 2,
+              "child_entities": [
+                { "id": 3, "grandchild_entities": [] },
+                { "id": 6, "grandchild_entities": [] } ]
+            }
+          ]|]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-1/2" ]
+          }
+
+      it "works with views" $ do
+        get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith`
+          [json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/authors?select=*,books!inner(*)&books.title=eq.1984" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-0/1" ]
+          }
+
+    context "many-to-many relationships" $ do
+      it "ignores empty array embeddings while the default left join doesn't" $ do
+        get "/products?select=id,suppliers!inner(id)" `shouldRespondWith`
+          [json| [
+            {"id":1,"suppliers":[{"id":1}, {"id":2}]},
+            {"id":2,"suppliers":[{"id":1}, {"id":3}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/products?select=id,suppliers!left(id)" `shouldRespondWith`
+          [json| [
+            {"id":1,"suppliers":[{"id":1}, {"id":2}]},
+            {"id":2,"suppliers":[{"id":1}, {"id":3}]},
+            {"id":3,"suppliers":[]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/products?select=id,suppliers!inner(id)" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-1/2" ]
+          }
+
+      it "filters source tables when the embedded table is filtered" $ do
+        get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" `shouldRespondWith`
+          [json| [{"id":1,"suppliers":[{"id":2}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.3" `shouldRespondWith`
+          [json| [{"id":2,"suppliers":[{"id":3}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith`
+          [json| [] |]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-0/1" ]
+          }
+
+      it "filters source tables when a two levels below embedded table is filtered" $ do
+        get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3"
+          `shouldRespondWith`
+          [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.4"
+          `shouldRespondWith`
+          [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-0/1" ]
+          }
+
+      it "only affects the source table rows if his direct embedding is an inner join" $ do
+        get "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" `shouldRespondWith`
+          [json|[
+            {"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]},
+            {"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-1/2" ]
+          }
+
+      it "works with views" $ do
+        get "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" `shouldRespondWith`
+          [json| [{"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith`
+          [json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        request methodHead "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" [("Prefer", "count=exact")] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson
+                           , "Content-Range" <:> "0-0/1" ]
+          }
+
+    it "works with m2o and m2m relationships combined" $ do
+      get "/projects?select=name,clients!inner(name),users!inner(name)" `shouldRespondWith`
+        [json| [
+          {"name":"Windows 7","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}, {"name":"Dwight Schrute"}]},
+          {"name":"Windows 10","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}]},
+          {"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]},
+          {"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+      request methodHead "/projects?select=name,clients!inner(name),users!inner(name)" [("Prefer", "count=exact")] mempty
+        `shouldRespondWith` ""
+        { matchStatus  = 200
+        , matchHeaders = [ matchContentTypeJson
+                         , "Content-Range" <:> "0-3/4" ]
+        }
+
+    it "works with rpc" $ do
+      get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
+        [json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      request methodHead "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
+        `shouldRespondWith` ""
+        { matchStatus  = 200
+        , matchHeaders = [ matchContentTypeJson
+                         , "Content-Range" <:> "0-1/2" ]
+        }
+
+    it "works when using hints" $ do
+      get "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" `shouldRespondWith`
+        [json| [{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith`
+        [json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      request methodHead "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" [("Prefer", "count=exact")] mempty
+        `shouldRespondWith` ""
+        { matchStatus  = 200
+        , matchHeaders = [ matchContentTypeJson
+                         , "Content-Range" <:> "0-1/2" ]
+        }
+
+    it "works with many one-to-many relationships" $ do
+      -- https://github.com/PostgREST/postgrest/issues/1977
+      get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" `shouldRespondWith`
+        [json|[
+          {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}, {"name":"Wilma Wellers"}],"clientinfo":[{"other":"123 Main St"}]},
+          {"id":2,"name":"Target", "contact":[{"name":"Tabby Targo"}],"clientinfo":[{"other":"456 South 3rd St"}]},
+          {"id":3,"name":"Big Lots","contact":[{"name":"Bobby Bots"}, {"name":"Bonnie Bits"}, {"name":"Billy Boats"}],"clientinfo":[{"other":"789 Palm Tree Ln"}]}
+        ]|]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&contact.name=eq.Wally%20Walton" `shouldRespondWith`
+        [json|[
+          {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}],"clientinfo":[{"other":"123 Main St"}]}
+        ]|]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&clientinfo.other=eq.456%20South%203rd%20St" `shouldRespondWith`
+        [json|[
+          {"id":2,"name":"Target","clientinfo":[{"other":"456 South 3rd St"}],"contact":[{"name":"Tabby Targo"}]}
+        ]|]
+        { matchHeaders = [matchContentTypeJson] }
+      request methodHead "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" [("Prefer", "count=exact")] mempty
+        `shouldRespondWith` ""
+        { matchStatus  = 200
+        , matchHeaders = [ matchContentTypeJson
+                         , "Content-Range" <:> "0-2/3" ]
+        }
diff --git a/test/spec/Feature/ExtraSearchPathSpec.hs b/test/spec/Feature/ExtraSearchPathSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/ExtraSearchPathSpec.hs
@@ -0,0 +1,39 @@
+module Feature.ExtraSearchPathSpec where
+
+import Network.HTTP.Types
+import Network.Wai         (Application)
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec = describe "extra search path" $ do
+
+  it "finds the ltree <@ operator on the public schema" $
+    request methodGet "/ltree_sample?path=cd.Top.Science.Astronomy" [] ""
+      `shouldRespondWith` [json|[
+        {"path":"Top.Science.Astronomy"},
+        {"path":"Top.Science.Astronomy.Astrophysics"},
+        {"path":"Top.Science.Astronomy.Cosmology"}]|]
+      { matchHeaders = [matchContentTypeJson] }
+
+  it "finds the ltree nlevel function on the public schema, used through a computed column" $
+    request methodGet "/ltree_sample?select=number_of_labels&path=eq.Top.Science" [] ""
+      `shouldRespondWith` [json|[{"number_of_labels":2}]|]
+      { matchHeaders = [matchContentTypeJson] }
+
+  it "finds the isn = operator on the extensions schema" $
+    request methodGet "/isn_sample?id=eq.978-0-393-04002-9&select=name" [] ""
+      `shouldRespondWith` [json|[{"name":"Mathematics: From the Birth of Numbers"}]|]
+      { matchHeaders = [matchContentTypeJson] }
+
+  it "finds the isn is_valid function on the extensions schema" $
+    request methodGet "/rpc/is_valid_isbn?input=978-0-393-04002-9" [] ""
+      `shouldRespondWith` [json|true|]
+      { matchHeaders = [matchContentTypeJson] }
+
+  it "can detect fk relations through multiple views recursively when middle views are in extra search path" $
+    get "/consumers_extra_view?select=*,orders_view(*)" `shouldRespondWith` 200
diff --git a/test/spec/Feature/HtmlRawOutputSpec.hs b/test/spec/Feature/HtmlRawOutputSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/HtmlRawOutputSpec.hs
@@ -0,0 +1,30 @@
+module Feature.HtmlRawOutputSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec         hiding (pendingWith)
+import Test.Hspec.Wai
+import Text.Heredoc
+
+import Protolude  hiding (get)
+import SpecHelper (acceptHdrs)
+
+spec :: SpecWith ((), Application)
+spec = describe "When raw-media-types is set to \"text/html\"" $
+  it "can get raw output with Accept: text/html" $
+    request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
+      `shouldRespondWith`
+      [str|
+          |<html>
+          |  <head>
+          |    <title>PostgREST</title>
+          |  </head>
+          |  <body>
+          |    <h1>Welcome to PostgREST</h1>
+          |  </body>
+          |</html>
+          |]
+      { matchStatus = 200
+      , matchHeaders = ["Content-Type" <:> "text/html"]
+      }
diff --git a/test/spec/Feature/IgnorePrivOpenApiSpec.hs b/test/spec/Feature/IgnorePrivOpenApiSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/IgnorePrivOpenApiSpec.hs
@@ -0,0 +1,76 @@
+module Feature.IgnorePrivOpenApiSpec where
+
+import Control.Lens ((^?))
+
+import Data.Aeson.Lens
+import Data.Aeson.QQ
+
+import Network.HTTP.Types
+import Network.Wai        (Application)
+import Network.Wai.Test   (SResponse (..))
+
+import Test.Hspec     hiding (pendingWith)
+import Test.Hspec.Wai
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec = describe "OpenAPI Ignore Privileges" $ do
+  it "root path returns a valid openapi spec" $ do
+    validateOpenApiResponse [("Accept", "application/openapi+json")]
+    request methodHead "/" (acceptHdrs "application/openapi+json") ""
+      `shouldRespondWith` "" { matchStatus  = 200 }
+
+  describe "table" $ do
+
+    it "includes privileged table even if user does not have permission" $ do
+      r <- simpleBody <$> get "/"
+      let tableTag = r ^? key "paths" . key "/authors_only"
+                     . key "post" . key "tags"
+                     . nth 0
+
+      liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]
+
+    it "only includes tables that belong to another schema if the Accept-Profile header is used" $ do
+      r1 <- simpleBody <$> get "/"
+      let tableKey1 = r1 ^? key "paths" . key "/children"
+
+      liftIO $ tableKey1 `shouldBe` Nothing
+
+      r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] ""
+      let tableKey2 = r2 ^? key "paths" . key "/children"
+
+      liftIO $ tableKey2 `shouldNotBe` Nothing
+
+    it "includes comments on tables" $ do
+      r <- simpleBody <$> get "/"
+
+      let grandChildGet s = key "paths" . key "/grandchild_entities" . key "get" . key s
+          grandChildGetSummary = r ^? grandChildGet "summary"
+          grandChildGetDescription = r ^? grandChildGet "description"
+
+      liftIO $ do
+        grandChildGetSummary `shouldBe` Just "grandchild_entities summary"
+        grandChildGetDescription `shouldBe` Just "grandchild_entities description\nthat spans\nmultiple lines"
+
+  describe "RPC" $ do
+
+    it "includes privileged function even if user does not have permission" $ do
+      r <- simpleBody <$> get "/"
+      let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"
+                    . key "post" . key "tags"
+                    . nth 0
+
+      liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]
+
+    it "only includes functions that belong to another schema if the Accept-Profile header is used" $ do
+      r1 <- simpleBody <$> get "/"
+      let funcKey1 = r1 ^? key "paths" . key "/rpc/get_parents_below"
+
+      liftIO $ funcKey1 `shouldBe` Nothing
+
+      r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] ""
+      let funcKey2 = r2 ^? key "paths" . key "/rpc/get_parents_below"
+
+      liftIO $ funcKey2 `shouldNotBe` Nothing
diff --git a/test/spec/Feature/InsertSpec.hs b/test/spec/Feature/InsertSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/InsertSpec.hs
@@ -0,0 +1,558 @@
+module Feature.InsertSpec where
+
+import Data.List              (lookup)
+import Network.Wai            (Application)
+import Network.Wai.Test       (SResponse (simpleHeaders))
+import Test.Hspec             hiding (pendingWith)
+import Test.Hspec.Wai.Matcher (bodyEquals)
+
+import Network.HTTP.Types
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+import Text.Heredoc
+
+import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
+                                   pgVersion112, pgVersion130)
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion = do
+  describe "Posting new record" $ do
+    context "disparate json types" $ do
+      it "accepts disparate json types" $ do
+        post "/menagerie"
+          [json| {
+            "integer": 13, "double": 3.14159, "varchar": "testing!"
+          , "boolean": false, "date": "1900-01-01", "money": "$3.99"
+          , "enum": "foo"
+          } |] `shouldRespondWith` ""
+          { matchStatus  = 201
+            -- should not have content type set when body is empty
+          , matchHeaders = [matchHeaderAbsent hContentType]
+          }
+
+      it "filters columns in result using &select" $
+        request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=representation")]
+          [json| [{
+            "integer": 14, "double": 3.14159, "varchar": "testing!"
+          , "boolean": false, "date": "1900-01-01", "money": "$3.99"
+          , "enum": "foo"
+          }] |] `shouldRespondWith` [json|[{"integer":14,"varchar":"testing!"}]|]
+          { matchStatus  = 201
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+      it "ignores &select when return not set or using return=minimal" $ do
+        request methodPost "/menagerie?select=integer,varchar" []
+          [json| [{
+            "integer": 15, "double": 3.14159, "varchar": "testing!"
+          , "boolean": false, "date": "1900-01-01", "money": "$3.99"
+          , "enum": "foo"
+          }] |] `shouldRespondWith` ""
+          { matchStatus  = 201 }
+        request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=minimal")]
+          [json| [{
+            "integer": 16, "double": 3.14159, "varchar": "testing!"
+          , "boolean": false, "date": "1900-01-01", "money": "$3.99"
+          , "enum": "foo"
+          }] |] `shouldRespondWith` ""
+          { matchStatus  = 201 }
+
+    context "non uniform json array" $ do
+      it "rejects json array that isn't exclusivily composed of objects" $
+        post "/articles"
+             [json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |]
+        `shouldRespondWith`
+             [json| {"message":"All object keys must match"} |]
+             { matchStatus  = 400
+             , matchHeaders = [matchContentTypeJson]
+             }
+
+      it "rejects json array that has objects with different keys" $
+        post "/articles"
+             [json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]
+        `shouldRespondWith`
+             [json| {"message":"All object keys must match"} |]
+             { matchStatus  = 400
+             , matchHeaders = [matchContentTypeJson]
+             }
+
+    context "requesting full representation" $ do
+      it "includes related data after insert" $
+        request methodPost "/projects?select=id,name,clients(id,name)"
+                [("Prefer", "return=representation"), ("Prefer", "count=exact")]
+          [json|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [json|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]
+          { matchStatus  = 201
+          , matchHeaders = [ matchContentTypeJson
+                           , "Location" <:> "/projects?id=eq.6"
+                           , "Content-Range" <:> "*/1" ]
+          }
+
+      it "can rename and cast the selected columns" $
+        request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"
+                [("Prefer", "return=representation")]
+          [json|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith`
+          [json|[{"pId":"7","pName":"New Project","cId":"2"}]|]
+          { matchStatus  = 201
+          , matchHeaders = [ matchContentTypeJson
+                           , "Location" <:> "/projects?id=eq.7"
+                           , "Content-Range" <:> "*/*" ]
+          }
+
+      it "should not throw and return location header when selecting without PK" $
+        request methodPost "/projects?select=name,client_id" [("Prefer", "return=representation")]
+          [json|{"id":10,"name":"New Project","client_id":2}|] `shouldRespondWith`
+          [json|[{"name":"New Project","client_id":2}]|]
+          { matchStatus  = 201
+          , matchHeaders = [ matchContentTypeJson
+                           , "Location" <:> "/projects?id=eq.10"
+                           , "Content-Range" <:> "*/*" ]
+          }
+
+    context "requesting headers only representation" $ do
+      it "should not throw and return location header when selecting without PK" $
+        request methodPost "/projects?select=name,client_id" [("Prefer", "return=headers-only")]
+          [json|{"id":11,"name":"New Project","client_id":2}|] `shouldRespondWith` ""
+          { matchStatus  = 201
+          , matchHeaders = [ "Location" <:> "/projects?id=eq.11"
+                           , "Content-Range" <:> "*/*" ]
+          }
+
+      when (actualPgVersion >= pgVersion110) $
+        it "should not throw and return location header for partitioned tables when selecting without PK" $
+          request methodPost "/car_models" [("Prefer", "return=headers-only")]
+            [json|{"name":"Enzo","year":2021}|] `shouldRespondWith` ""
+            { matchStatus  = 201
+            , matchHeaders = [ "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021"
+                             , "Content-Range" <:> "*/*" ]
+            }
+
+    context "requesting no representation" $
+      it "should not throw and return no location header when selecting without PK" $
+        request methodPost "/projects?select=name,client_id" []
+          [json|{"id":12,"name":"New Project","client_id":2}|] `shouldRespondWith` ""
+          { matchStatus  = 201
+          , matchHeaders = [ matchHeaderAbsent hLocation ]
+          }
+
+    context "from an html form" $
+      it "accepts disparate json types" $ do
+        request methodPost "/menagerie"
+            [("Content-Type", "application/x-www-form-urlencoded")]
+             ("integer=7&double=2.71828&varchar=forms+are+fun&" <>
+              "boolean=false&date=1900-01-01&money=$3.99&enum=foo")
+          `shouldRespondWith`
+            ""
+            { matchStatus = 201 }
+
+    context "with no pk supplied" $ do
+      context "into a table with auto-incrementing pk" $
+        it "succeeds with 201 and location header" $ do
+          -- reset pk sequence first to make test repeatable
+          request methodPost "/rpc/reset_sequence"
+              [("Prefer", "tx=commit")]
+              [json|{"name": "auto_incrementing_pk_id_seq", "value": 2}|]
+            `shouldRespondWith`
+              [json|""|]
+
+          request methodPost "/auto_incrementing_pk" [("Prefer", "return=headers-only")]
+              [json| { "non_nullable_string":"not null"} |]
+            `shouldRespondWith`
+              ""
+              { matchStatus  = 201
+              , matchHeaders = [ "Location" <:> "/auto_incrementing_pk?id=eq.2" ]
+              }
+
+      context "into a table with simple pk" $
+        it "fails with 400 and error" $
+          post "/simple_pk" [json| { "extra":"foo"} |]
+          `shouldRespondWith`
+          (if actualPgVersion >= pgVersion130 then
+            [json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" of relation \"simple_pk\" violates not-null constraint"}|]
+           else
+            [json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]
+          )
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+      context "into a table with no pk" $ do
+        it "succeeds with 201 but no location header" $ do
+          post "/no_pk" [json| { "a":"foo", "b":"bar" } |]
+            `shouldRespondWith`
+              ""
+              { matchStatus  = 201
+              , matchHeaders = [matchHeaderAbsent hLocation]
+              }
+
+        it "returns full details of inserted record if asked" $ do
+          request methodPost "/no_pk"
+              [("Prefer", "return=representation")]
+              [json| { "a":"bar", "b":"baz" } |]
+            `shouldRespondWith`
+              [json| [{ "a":"bar", "b":"baz" }] |]
+              { matchStatus  = 201
+              , matchHeaders = [matchHeaderAbsent hLocation]
+              }
+
+        it "returns empty array when no items inserted, and return=rep" $ do
+          request methodPost "/no_pk"
+              [("Prefer", "return=representation")]
+              [json| [] |]
+            `shouldRespondWith`
+              [json| [] |]
+              { matchStatus = 201 }
+
+        it "can post nulls" $ do
+          request methodPost "/no_pk"
+              [("Prefer", "return=representation")]
+              [json| { "a":null, "b":"foo" } |]
+            `shouldRespondWith`
+              [json| [{ "a":null, "b":"foo" }] |]
+              { matchStatus  = 201
+              , matchHeaders = [matchHeaderAbsent hLocation]
+              }
+
+    context "with compound pk supplied" $
+      it "builds response location header appropriately" $ do
+        request methodPost "/compound_pk"
+            [("Prefer", "return=representation")]
+            [json| { "k1":12, "k2":"Rock & R+ll" } |]
+          `shouldRespondWith`
+            [json|[ { "k1":12, "k2":"Rock & R+ll", "extra": null } ]|]
+            { matchStatus  = 201
+            , matchHeaders = [ "Location" <:> "/compound_pk?k1=eq.12&k2=eq.Rock%20%26%20R%2Bll" ]
+            }
+
+    context "with bulk insert" $
+      it "returns 201 but no location header" $ do
+        let bulkData = [json| [ {"k1":21, "k2":"hello world"}
+                              , {"k1":22, "k2":"bye for now"}]
+                            |]
+        request methodPost "/compound_pk" [] bulkData
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 201
+            , matchHeaders = [matchHeaderAbsent hLocation]
+            }
+
+    context "with invalid json payload" $
+      it "fails with 400 and error" $
+        post "/simple_pk" "}{ x = 2"
+        `shouldRespondWith`
+        [json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
+        { matchStatus  = 400
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+    context "with no payload" $
+      it "fails with 400 and error" $
+        post "/simple_pk" ""
+        `shouldRespondWith`
+        [json|{"message":"Error in $: not enough input"}|]
+        { matchStatus  = 400
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+    context "with valid json payload" $
+      it "succeeds and returns 201 created" $
+        post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 201
+
+    context "attempting to insert a row with the same primary key" $
+      it "fails returning a 409 Conflict" $
+        post "/simple_pk"
+            [json| { "k":"xyyx", "extra":"e1" } |]
+          `shouldRespondWith`
+            [json|{"hint":null,"details":"Key (k)=(xyyx) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"simple_pk_pkey\""}|]
+            { matchStatus  = 409 }
+
+    context "attempting to insert a row with conflicting unique constraint" $
+      it "fails returning a 409 Conflict" $
+        post "/withUnique"  [json| { "uni":"nodup", "extra":"e2" } |] `shouldRespondWith` 409
+
+    context "jsonb" $ do
+      it "serializes nested object" $ do
+        let inserted = [json| { "data": { "foo":"bar" } } |]
+        request methodPost "/json_table"
+                     [("Prefer", "return=representation")]
+                     inserted
+          `shouldRespondWith` [json|[{"data":{"foo":"bar"}}]|]
+          { matchStatus  = 201
+          }
+
+      it "serializes nested array" $ do
+        let inserted = [json| { "data": [1,2,3] } |]
+        request methodPost "/json_table"
+                     [("Prefer", "return=representation")]
+                     inserted
+          `shouldRespondWith` [json|[{"data":[1,2,3]}]|]
+          { matchStatus  = 201
+          }
+
+    context "empty objects" $ do
+      it "successfully inserts a row with all-default columns" $ do
+        post "/items" "{}" `shouldRespondWith` ""
+          { matchStatus  = 201
+          , matchHeaders = []
+          }
+        post "/items" "[{}]" `shouldRespondWith` ""
+          { matchStatus  = 201
+          , matchHeaders = []
+          }
+
+      it "successfully inserts two rows with all-default columns" $
+        post "/items" "[{}, {}]" `shouldRespondWith` ""
+          { matchStatus  = 201
+          , matchHeaders = []
+          }
+
+      it "successfully inserts a row with all-default columns with prefer=rep" $ do
+        -- reset pk sequence first to make test repeatable
+        request methodPost "/rpc/reset_sequence"
+            [("Prefer", "tx=commit")]
+            [json|{"name": "items2_id_seq", "value": 20}|]
+          `shouldRespondWith`
+            [json|""|]
+
+        request methodPost "/items2"
+            [("Prefer", "return=representation")]
+            [json|{}|]
+          `shouldRespondWith`
+            [json|[{ id: 20 }]|]
+            { matchStatus  = 201 }
+
+      it "successfully inserts a row with all-default columns with prefer=rep and &select=" $ do
+        -- reset pk sequence first to make test repeatable
+        request methodPost "/rpc/reset_sequence"
+            [("Prefer", "tx=commit")]
+            [json|{"name": "items3_id_seq", "value": 20}|]
+          `shouldRespondWith`
+            [json|""|]
+
+        request methodPost "/items3?select=id"
+            [("Prefer", "return=representation")]
+            [json|{}|]
+          `shouldRespondWith` [json|[{ id: 20 }]|]
+            { matchStatus  = 201 }
+
+    context "POST with ?columns parameter" $ do
+      it "ignores json keys not included in ?columns" $ do
+        request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")]
+          [json| {"id": 200, "body": "xxx", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`
+          [json|[{"id": 200, "body": "xxx", "owner": "postgrest_test_anonymous"}]|]
+          { matchStatus  = 201
+          , matchHeaders = [] }
+        request methodPost "/articles?columns=id,body&select=id,body" [("Prefer", "return=representation")]
+          [json| [
+            {"id": 201, "body": "yyy", "smth": "here", "other": "stuff", "fake_id": 13},
+            {"id": 202, "body": "zzz", "garbage": "%%$&", "kkk": "jjj"},
+            {"id": 203, "body": "aaa", "hey": "ho"} ]|] `shouldRespondWith`
+          [json|[
+            {"id": 201, "body": "yyy"},
+            {"id": 202, "body": "zzz"},
+            {"id": 203, "body": "aaa"} ]|]
+          { matchStatus  = 201
+          , matchHeaders = [] }
+
+      -- TODO parse columns error message needs to be improved
+      it "disallows blank ?columns" $
+        post "/articles?columns="
+          [json|[
+            {"id": 204, "body": "yyy"},
+            {"id": 205, "body": "zzz"}]|]
+          `shouldRespondWith`
+          [json|  {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)"} |]
+          { matchStatus  = 400
+          , matchHeaders = []
+          }
+
+      it "disallows array elements that are not json objects" $
+        post "/articles?columns=id,body"
+          [json|[
+            {"id": 204, "body": "yyy"},
+            333,
+            "asdf",
+            {"id": 205, "body": "zzz"}]|] `shouldRespondWith`
+          [json|{
+              "code": "22023",
+              "details": null,
+              "hint": null,
+              "message": "argument of json_populate_recordset must be an array of objects"}|]
+          { matchStatus  = 400
+          , matchHeaders = []
+          }
+
+  describe "CSV insert" $ do
+    context "disparate csv types" $
+      it "succeeds with multipart response" $ do
+        pendingWith "Decide on what to do with CSV insert"
+        let inserted = [str|integer,double,varchar,boolean,date,money,enum
+            |13,3.14159,testing!,false,1900-01-01,$3.99,foo
+            |12,0.1,a string,true,1929-10-01,12,bar
+            |]
+        request methodPost "/menagerie" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] inserted
+           `shouldRespondWith` ResponseMatcher
+           { matchStatus  = 201
+           , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
+           , matchBody = bodyEquals inserted
+           }
+
+    context "requesting full representation" $ do
+      it "returns full details of inserted record" $
+        request methodPost "/no_pk"
+                     [("Content-Type", "text/csv"), ("Accept", "text/csv"),  ("Prefer", "return=representation")]
+                     "a,b\nbar,baz"
+          `shouldRespondWith` "a,b\nbar,baz"
+          { matchStatus  = 201
+          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
+          }
+
+      it "can post nulls" $
+        request methodPost "/no_pk"
+                     [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
+                     "a,b\nNULL,foo"
+          `shouldRespondWith` "a,b\n,foo"
+          { matchStatus  = 201
+          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
+          }
+
+      it "only returns the requested column header with its associated data" $
+        request methodPost "/projects?select=id"
+                     [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
+                     "id,name,client_id\n8,Xenix,1\n9,Windows NT,1"
+          `shouldRespondWith` "id\n8\n9"
+          { matchStatus  = 201
+          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
+                            "Content-Range" <:> "*/*"]
+          }
+
+    context "with wrong number of columns" $
+      it "fails for too few" $
+        request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
+        `shouldRespondWith`
+        [json|{"message":"All lines must have same number of fields"}|]
+        { matchStatus  = 400
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+    context "with unicode values" $
+      it "succeeds and returns usable location header" $ do
+        p <- request methodPost "/simple_pk2?select=extra,k"
+            [("Prefer", "tx=commit"), ("Prefer", "return=representation")]
+            [json| { "k":"圍棋", "extra":"￥" } |]
+        pure p `shouldRespondWith`
+          [json|[ { "k":"圍棋", "extra":"￥" } ]|]
+          { matchStatus = 201 }
+
+        let Just location = lookup hLocation $ simpleHeaders p
+        get location
+          `shouldRespondWith`
+            [json|[ { "k":"圍棋", "extra":"￥" } ]|]
+
+        request methodDelete location
+            [("Prefer", "tx=commit")]
+            ""
+          `shouldRespondWith`
+            204
+
+  describe "Row level permission" $
+    it "set user_id when inserting rows" $ do
+      request methodPost "/authors_only"
+          [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0", ("Prefer", "return=representation") ]
+          [json| { "secret": "nyancat" } |]
+        `shouldRespondWith`
+          [json|[{"owner":"jdoe","secret":"nyancat"}]|]
+          { matchStatus  = 201 }
+
+      request methodPost "/authors_only"
+          -- jwt token for jroe
+          [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.2e7mx0U4uDcInlbJVOBGlrRufwqWLINDIEDC1vS0nw8", ("Prefer", "return=representation") ]
+          [json| { "secret": "lolcat", "owner": "hacker" } |]
+        `shouldRespondWith`
+          [json|[{"owner":"jroe","secret":"lolcat"}]|]
+          { matchStatus  = 201 }
+
+  context "tables with self reference foreign keys" $ do
+    it "embeds parent after insert" $
+      request methodPost "/web_content?select=id,name,parent_content:p_web_id(name)"
+              [("Prefer", "return=representation")]
+        [json|{"id":6, "name":"wot", "p_web_id":4}|]
+        `shouldRespondWith`
+        [json|[{"id":6,"name":"wot","parent_content":{"name":"wut"}}]|]
+        { matchStatus  = 201
+        , matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]
+        }
+
+  context "table with limited privileges" $ do
+    it "succeeds inserting if correct select is applied" $
+      request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
+        [json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [json|[{"article_id":2,"user_id":1}]|]
+        { matchStatus  = 201
+        , matchHeaders = []
+        }
+
+    it "fails inserting if more columns are selected" $
+      request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
+        [json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
+      if actualPgVersion >= pgVersion112 then
+      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
+         else
+      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
+                                                                      )
+        { matchStatus  = 401
+        , matchHeaders = []
+        }
+
+    it "fails inserting if select is not specified" $
+      request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
+        [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
+      if actualPgVersion >= pgVersion112 then
+      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
+         else
+      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
+                                                                      )
+        { matchStatus  = 401
+        , matchHeaders = []
+        }
+
+    it "can insert in a table with no select and return=minimal" $ do
+      request methodPost "/insertonly"
+          [("Prefer", "return=minimal")]
+          [json| { "v":"some value" } |]
+        `shouldRespondWith`
+          ""
+          { matchStatus = 201 }
+
+  describe "Inserting into VIEWs" $ do
+    context "requesting no representation" $
+      it "succeeds with 201" $
+        post "/compound_pk_view"
+            [json|{"k1":1,"k2":"test","extra":2}|]
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 201
+            , matchHeaders = [ matchHeaderAbsent hLocation ]
+            }
+
+    context "requesting header only representation" $ do
+      it "returns a location header" $
+        request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")]
+            [json|{"k1":1,"k2":"test","extra":2}|]
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 201
+            , matchHeaders = [ "Location" <:> "/compound_pk_view?k1=eq.1&k2=eq.test"
+                             , "Content-Range" <:> "*/*" ]
+            }
+
+      it "should not throw and return location header when a PK is null" $
+        request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")]
+            [json|{"id":1}|]
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 201
+            , matchHeaders = [ "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"
+                             , "Content-Range" <:> "*/*" ]
+            }
diff --git a/test/spec/Feature/JsonOperatorSpec.hs b/test/spec/Feature/JsonOperatorSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/JsonOperatorSpec.hs
@@ -0,0 +1,253 @@
+module Feature.JsonOperatorSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import PostgREST.Config.PgVersion (PgVersion, pgVersion112,
+                                   pgVersion121)
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion = describe "json and jsonb operators" $ do
+  context "Shaping response with select parameter" $ do
+    it "obtains a json subfield one level with casting" $
+      get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
+        [json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "renames json subfield one level with casting" $
+      get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
+        [json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "fails on bad casting (data of the wrong format)" $
+      get "/complex_items?select=settings->foo->>bar::integer"
+        `shouldRespondWith` (
+        if actualPgVersion >= pgVersion121 then
+        [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]
+        else
+        [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
+                            )
+        { matchStatus  = 400 , matchHeaders = [] }
+
+    it "obtains a json subfield two levels (string)" $
+      get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
+        [json| [{"bar":"baz"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "renames json subfield two levels (string)" $
+      get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
+        [json| [{"myBar":"baz"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "obtains a json subfield two levels with casting (int)" $
+      get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
+        [json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "renames json subfield two levels with casting (int)" $
+      get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
+        [json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
+        { matchHeaders = [matchContentTypeJson] }
+
+    -- TODO the status code for the error is 404, this is because 42883 represents undefined function
+    -- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate
+    it "fails when a double arrow ->> is followed with a single arrow ->" $ do
+      get "/json_arr?select=data->>c->1"
+        `shouldRespondWith` (
+        if actualPgVersion >= pgVersion112 then
+        [json|
+          {"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
+           "details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
+           else
+        [json|
+          {"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
+           "details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
+                            )
+        { matchStatus  = 404 , matchHeaders = [] }
+      get "/json_arr?select=data->>c->b"
+        `shouldRespondWith` (
+        if actualPgVersion >= pgVersion112 then
+        [json|
+          {"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
+           "details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
+           else
+        [json|
+          {"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
+           "details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
+                            )
+        { matchStatus  = 404 , matchHeaders = [] }
+
+    context "with array index" $ do
+      it "can get array of ints and alias/cast it" $ do
+        get "/json_arr?select=data->>0::int&id=in.(1,2)" `shouldRespondWith`
+          [json| [{"data":1}, {"data":4}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/json_arr?select=idx0:data->>0::int,idx1:data->>1::int&id=in.(1,2)" `shouldRespondWith`
+          [json| [{"idx0":1,"idx1":2}, {"idx0":4,"idx1":5}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "can get nested array of ints" $ do
+        get "/json_arr?select=data->0->>1::int&id=in.(3,4)" `shouldRespondWith`
+          [json| [{"data":8}, {"data":7}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/json_arr?select=data->0->0->>1::int&id=in.(3,4)" `shouldRespondWith`
+          [json| [{"data":null}, {"data":6}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "can get array of objects" $ do
+        get "/json_arr?select=data->0->>a&id=in.(5,6)" `shouldRespondWith`
+          [json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/json_arr?select=data->0->a->>2&id=in.(5,6)" `shouldRespondWith`
+          [json| [{"a":null}, {"a":"3"}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "can get array in object keys" $ do
+        get "/json_arr?select=data->c->>0::json&id=in.(7,8)" `shouldRespondWith`
+          [json| [{"c":1}, {"c":{"d": [4,5,6,7,8]}}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/json_arr?select=data->c->0->d->>4::int&id=in.(7,8)" `shouldRespondWith`
+          [json| [{"d":null}, {"d":8}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "only treats well formed numbers as indexes" $
+        get "/json_arr?select=data->0->0xy1->1->23-xy-45->1->xy-6->>0::int&id=eq.9" `shouldRespondWith`
+          [json| [{"xy-6":3}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+    context "finishing json path with single arrow ->" $ do
+      it "works when finishing with a key" $ do
+        get "/json_arr?select=data->c&id=in.(7,8)" `shouldRespondWith`
+          [json| [{"c":[1,2,3]}, {"c":[{"d": [4,5,6,7,8]}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
+          [json| [{"a":"A"}, {"a":[1,2,3]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "works when finishing with an index" $ do
+        get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
+          [json| [{"a":"A"}, {"a":[1,2,3]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/json_arr?select=data->c->0->d&id=eq.8" `shouldRespondWith`
+          [json| [{"d":[4,5,6,7,8]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+  context "filtering response" $ do
+    it "can filter by properties inside json column" $ do
+      get "/json_table?data->foo->>bar=eq.baz" `shouldRespondWith`
+        [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/json_table?data->foo->>bar=eq.fake" `shouldRespondWith`
+        [json| [] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "can filter by properties inside json column using not" $
+      get "/json_table?data->foo->>bar=not.eq.baz" `shouldRespondWith`
+        [json| [] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "can filter by properties inside json column using ->>" $
+      get "/json_table?data->>id=eq.1" `shouldRespondWith`
+        [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "can be filtered with and/or" $
+      get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
+        [json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
+
+    it "can filter by array indexes" $ do
+      get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`
+        [json| [{"data":[1, 2, 3]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data&data->1->>2=eq.13" `shouldRespondWith`
+        [json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data&data->1->>b=eq.B" `shouldRespondWith`
+        [json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data&data->1->b->>1=eq.5" `shouldRespondWith`
+        [json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "can filter jsonb" $ do
+      get "/jsonb_test?data=eq.{\"e\":1}" `shouldRespondWith`
+        [json| [{"id":4,"data":{"e": 1}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/jsonb_test?data->a=eq.{\"b\":2}" `shouldRespondWith`
+        [json| [{"id":1,"data":{"a": {"b": 2}}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/jsonb_test?data->c=eq.[1,2,3]" `shouldRespondWith`
+        [json| [{"id":2,"data":{"c": [1, 2, 3]}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/jsonb_test?data->0=eq.{\"d\":\"test\"}" `shouldRespondWith`
+        [json| [{"id":3,"data":[{"d": "test"}]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+  context "ordering response" $ do
+    it "orders by a json column property asc" $
+      get "/json_table?order=data->>id.asc" `shouldRespondWith`
+        [json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "orders by a json column with two level property nulls first" $
+      get "/json_table?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
+        [json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+  context "Patching record, in a nonempty table" $
+    it "can set a json column to escaped value" $ do
+      request methodPatch "/json_table?data->>id=eq.3"
+          [("Prefer", "return=representation")]
+          [json| { "data": { "id":" \"escaped" } } |]
+        `shouldRespondWith`
+          [json| [{ "data": { "id":" \"escaped" } }] |]
+
+  context "json array negative index" $ do
+    it "can select with negative indexes" $ do
+      get "/json_arr?select=data->>-1::int&id=in.(1,2)" `shouldRespondWith`
+        [json| [{"data":3}, {"data":6}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data->0->>-2::int&id=in.(3,4)" `shouldRespondWith`
+        [json| [{"data":8}, {"data":7}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data->-2->>a&id=in.(5,6)" `shouldRespondWith`
+        [json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "can filter with negative indexes" $ do
+      get "/json_arr?select=data&data->>-3=eq.1" `shouldRespondWith`
+        [json| [{"data":[1, 2, 3]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data&data->-1->>-3=eq.11" `shouldRespondWith`
+        [json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data&data->-1->>b=eq.B" `shouldRespondWith`
+        [json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data&data->-1->b->>-1=eq.5" `shouldRespondWith`
+        [json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "should fail on badly formed negatives" $ do
+      get "/json_arr?select=data->>-78xy" `shouldRespondWith`
+        [json|
+          {"details": "unexpected 'x' expecting digit, \"->\", \"::\" or end of input",
+           "message": "\"failed to parse select parameter (data->>-78xy)\" (line 1, column 11)"} |]
+        { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data->>--34" `shouldRespondWith`
+        [json|
+          {"details": "unexpected \"-\" expecting digit",
+           "message": "\"failed to parse select parameter (data->>--34)\" (line 1, column 9)"} |]
+        { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+      get "/json_arr?select=data->>-xy-4" `shouldRespondWith`
+        [json|
+          {"details":"unexpected \"x\" expecting digit",
+           "message":"\"failed to parse select parameter (data->>-xy-4)\" (line 1, column 9)"} |]
+        { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
diff --git a/test/spec/Feature/LegacyGucsSpec.hs b/test/spec/Feature/LegacyGucsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/LegacyGucsSpec.hs
@@ -0,0 +1,68 @@
+module Feature.LegacyGucsSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec          hiding (pendingWith)
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "remote procedure call with legacy gucs disabled" $ do
+    it "custom header is set" $
+      request methodPost "/rpc/get_guc_value" [("Custom-Header", "test")]
+        [json| { "prefix": "request.headers", "name": "custom-header" } |]
+          `shouldRespondWith`
+          [json|"test"|]
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson ]
+          }
+
+    it "standard header is set" $
+      request methodPost "/rpc/get_guc_value" [("Origin", "http://example.com")]
+        [json| { "prefix": "request.headers", "name": "origin" } |]
+          `shouldRespondWith`
+          [json|"http://example.com"|]
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson ]
+          }
+
+    it "current role is available as GUC claim" $
+      request methodPost "/rpc/get_guc_value" []
+        [json| { "prefix": "request.jwt.claims", "name": "role" } |]
+          `shouldRespondWith`
+          [json|"postgrest_test_anonymous"|]
+          { matchStatus  = 200
+          , matchHeaders = [ matchContentTypeJson ]
+          }
+
+    it "single cookie ends up as claims" $
+      request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
+        [json| {"prefix": "request.cookies", "name":"acookie"} |]
+          `shouldRespondWith`
+          [json|"cookievalue"|]
+          { matchStatus = 200
+          , matchHeaders = []
+          }
+
+    it "multiple cookies ends up as claims" $
+      request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
+        [json| {"prefix": "request.cookies", "name":"secondcookie"} |]
+          `shouldRespondWith`
+          [json|"anothervalue"|]
+          { matchStatus = 200
+          , matchHeaders = []
+          }
+
+    it "gets the Authorization value" $
+      request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
+        [json| {"prefix": "request.headers", "name":"authorization"} |]
+          `shouldRespondWith`
+          [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
+          { matchStatus = 200
+          , matchHeaders = []
+          }
diff --git a/test/spec/Feature/MultipleSchemaSpec.hs b/test/spec/Feature/MultipleSchemaSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/MultipleSchemaSpec.hs
@@ -0,0 +1,331 @@
+module Feature.MultipleSchemaSpec where
+
+import Control.Lens    ((^?))
+import Data.Aeson.Lens
+import Data.Aeson.QQ
+
+import Network.HTTP.Types
+import Network.Wai        (Application)
+import Network.Wai.Test   (SResponse (simpleHeaders), simpleBody)
+
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "multiple schemas in single instance" $ do
+    context "Reading tables on different schemas" $ do
+      it "succeeds in reading table from default schema v1 if no schema is selected via header" $
+        request methodGet "/parents" [] "" `shouldRespondWith`
+          [json|[
+            {"id":1,"name":"parent v1-1"},
+            {"id":2,"name":"parent v1-2"}
+          ]|]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+          }
+
+      it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $
+        request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`
+          [json|[
+            {"id":1,"name":"parent v1-1"},
+            {"id":2,"name":"parent v1-2"}
+          ]|]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+          }
+
+      it "succeeds in reading table from schema v2" $
+        request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`
+          [json|[
+            {"id":3,"name":"parent v2-3"},
+            {"id":4,"name":"parent v2-4"}
+          ]|]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+          }
+
+      it "succeeds in reading another_table from schema v2" $
+        request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`
+          [json|[
+            {"id":5,"another_value":"value 5"},
+            {"id":6,"another_value":"value 6"}
+          ]|]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+          }
+
+      it "doesn't find another_table in schema v1" $
+        request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404
+
+      it "fails trying to read table from unkown schema" $
+        request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
+          [json|{"message":"The schema must be one of the following: v1, v2"}|]
+          {
+            matchStatus = 406
+          }
+
+    context "Inserting tables on different schemas" $ do
+      it "succeeds inserting on default schema and returning it" $
+        request methodPost "/children"
+            [("Prefer", "return=representation")]
+            [json|{"id": 0, "name": "child v1-1", "parent_id": 1}|]
+          `shouldRespondWith`
+            [json|[{"id": 0, "name": "child v1-1", "parent_id": 1}]|]
+            {
+              matchStatus = 201
+            , matchHeaders = ["Content-Profile" <:> "v1"]
+            }
+
+      it "succeeds inserting on the v1 schema and returning its parent" $
+        request methodPost "/children?select=id,parent(*)"
+            [("Prefer", "return=representation"), ("Content-Profile", "v1")]
+            [json|{"id": 0, "name": "child v1-2", "parent_id": 2}|]
+          `shouldRespondWith`
+            [json|[{"id": 0, "parent": {"id": 2, "name": "parent v1-2"}}]|]
+            {
+              matchStatus = 201
+            , matchHeaders = ["Content-Profile" <:> "v1"]
+            }
+
+      it "succeeds inserting on the v2 schema and returning its parent" $
+        request methodPost "/children?select=id,parent(*)"
+            [("Prefer", "return=representation"), ("Content-Profile", "v2")]
+            [json|{"id": 0, "name": "child v2-3", "parent_id": 3}|]
+          `shouldRespondWith`
+            [json|[{"id": 0, "parent": {"id": 3, "name": "parent v2-3"}}]|]
+            {
+              matchStatus = 201
+            , matchHeaders = ["Content-Profile" <:> "v2"]
+            }
+
+      it "fails when inserting on an unknown schema" $
+        request methodPost "/children" [("Content-Profile", "unknown")]
+          [json|{"name": "child 4", "parent_id": 4}|]
+          `shouldRespondWith`
+          [json|{"message":"The schema must be one of the following: v1, v2"}|]
+          {
+            matchStatus = 406
+          }
+
+    context "calling procs on different schemas" $ do
+      it "succeeds in calling the default schema proc" $
+        request methodGet "/rpc/get_parents_below?id=6" [] ""
+          `shouldRespondWith`
+          [json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+          }
+
+      it "succeeds in calling the v1 schema proc and embedding" $
+        request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v1")] ""
+          `shouldRespondWith`
+          [json| [
+            {"id":1,"name":"parent v1-1","children":[{"id":1,"name":"child v1-1"}]},
+            {"id":2,"name":"parent v1-2","children":[{"id":2,"name":"child v1-2"}]}] |]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+          }
+
+      it "succeeds in calling the v2 schema proc and embedding" $
+        request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v2")] ""
+          `shouldRespondWith`
+          [json| [
+            {"id":3,"name":"parent v2-3","children":[{"id":1,"name":"child v2-3"}]},
+            {"id":4,"name":"parent v2-4","children":[]}] |]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+          }
+
+      it "succeeds in calling the v2 schema proc with POST by using Content-Profile" $
+        request methodPost "/rpc/get_parents_below?select=id,name" [("Content-Profile", "v2")]
+          [json|{"id": "6"}|]
+          `shouldRespondWith`
+          [json| [
+            {"id":3,"name":"parent v2-3"},
+            {"id":4,"name":"parent v2-4"}]|]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+          }
+
+    context "Modifying tables on different schemas" $ do
+      it "succeeds in patching on the v1 schema and returning its parent" $
+        request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]
+          [json|{"name": "child v1-1 updated"}|]
+          `shouldRespondWith`
+          [json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
+          }
+
+      it "succeeds in patching on the v2 schema and returning its parent" $
+        request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
+          [json|{"name": "child v2-1 updated"}|]
+          `shouldRespondWith`
+          [json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+          }
+
+      it "succeeds on deleting on the v2 schema" $ do
+        request methodDelete "/children?id=eq.1"
+            [("Content-Profile", "v2"), ("Prefer", "return=representation")]
+            ""
+          `shouldRespondWith`
+            [json|[{"id": 1, "name": "child v2-3", "parent_id": 3}]|]
+            { matchHeaders = ["Content-Profile" <:> "v2"] }
+
+      it "succeeds on PUT on the v2 schema" $
+        request methodPut "/children?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
+          [json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]
+          `shouldRespondWith`
+          [json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]
+          {
+            matchStatus = 200
+          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
+          }
+
+    context "OpenAPI output" $ do
+      it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do
+          req <- request methodGet "/" [] ""
+
+          liftIO $ do
+            simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"
+
+            let def = simpleBody req ^? key "definitions" . key "parents"
+
+            def `shouldBe` Just
+                [aesonQQ|
+                  {
+                    "type" : "object",
+                    "properties" : {
+                      "id" : {
+                        "description" : "Note:\nThis is a Primary Key.<pk/>",
+                        "format" : "integer",
+                        "type" : "integer"
+                      },
+                      "name" : {
+                        "format" : "text",
+                        "type" : "string"
+                      }
+                    },
+                    "required" : [
+                      "id"
+                    ]
+                  }
+                |]
+
+      it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do
+          r <- request methodGet "/" [("Accept-Profile", "v1")] ""
+
+          liftIO $ do
+            simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"
+
+            let def = simpleBody r ^? key "definitions" . key "parents"
+
+            def `shouldBe` Just
+                [aesonQQ|
+                  {
+                    "type" : "object",
+                    "properties" : {
+                      "id" : {
+                        "description" : "Note:\nThis is a Primary Key.<pk/>",
+                        "format" : "integer",
+                        "type" : "integer"
+                      },
+                      "name" : {
+                        "format" : "text",
+                        "type" : "string"
+                      }
+                    },
+                    "required" : [
+                      "id"
+                    ]
+                  }
+                |]
+
+      it "succeeds in reading table definition from schema v2" $ do
+          r <- request methodGet "/" [("Accept-Profile", "v2")] ""
+
+          liftIO $ do
+            simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
+
+            let def = simpleBody r ^? key "definitions" . key "parents"
+
+            def `shouldBe` Just
+                [aesonQQ|
+                  {
+                    "type" : "object",
+                    "properties" : {
+                      "id" : {
+                        "description" : "Note:\nThis is a Primary Key.<pk/>",
+                        "format" : "integer",
+                        "type" : "integer"
+                      },
+                      "name" : {
+                        "format" : "text",
+                        "type" : "string"
+                      }
+                    },
+                    "required" : [
+                      "id"
+                    ]
+                  }
+                |]
+
+      it "succeeds in reading another_table definition from schema v2" $ do
+          r <- request methodGet "/" [("Accept-Profile", "v2")] ""
+
+          liftIO $ do
+            simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
+
+            let def = simpleBody r ^? key "definitions" . key "another_table"
+
+            def `shouldBe` Just
+                [aesonQQ|
+                  {
+                    "type" : "object",
+                    "properties" : {
+                      "id" : {
+                        "description" : "Note:\nThis is a Primary Key.<pk/>",
+                        "format" : "integer",
+                        "type" : "integer"
+                      },
+                      "another_value" : {
+                        "format" : "text",
+                        "type" : "string"
+                      }
+                    },
+                    "required" : [
+                      "id"
+                    ]
+                  }
+                |]
+
+      it "doesn't find another_table definition in schema v1" $ do
+        r <- request methodGet "/" [("Accept-Profile", "v1")] ""
+
+        liftIO $ do
+          let def = simpleBody r ^? key "definitions" . key "another_table"
+          def `shouldBe` Nothing
+
+      it "fails trying to read definitions from unkown schema" $
+        request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
+          [json|{"message":"The schema must be one of the following: v1, v2"}|]
+          {
+            matchStatus = 406
+          }
diff --git a/test/spec/Feature/NoJwtSpec.hs b/test/spec/Feature/NoJwtSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/NoJwtSpec.hs
@@ -0,0 +1,31 @@
+module Feature.NoJwtSpec where
+
+-- {{{ Imports
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude
+import SpecHelper
+-- }}}
+
+spec :: SpecWith ((), Application)
+spec = describe "server started without JWT secret" $ do
+
+  -- this test will stop working 9999999999s after the UNIX EPOCH
+  it "responds with error on attempted auth" $ do
+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
+    request methodGet "/authors_only" [auth] ""
+      `shouldRespondWith`
+      [json|{"message":"Server lacks JWT secret"}|]
+      { matchStatus  = 500
+      , matchHeaders = [ matchContentTypeJson ]
+      }
+
+  it "behaves normally when user does not attempt auth" $
+    request methodGet "/items" [] ""
+      `shouldRespondWith` 200
diff --git a/test/spec/Feature/NonexistentSchemaSpec.hs b/test/spec/Feature/NonexistentSchemaSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/NonexistentSchemaSpec.hs
@@ -0,0 +1,17 @@
+module Feature.NonexistentSchemaSpec where
+
+import Network.Wai (Application)
+
+import Test.Hspec
+import Test.Hspec.Wai
+
+import Protolude hiding (get)
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "Non existent api schema" $ do
+    it "succeeds when requesting root path" $
+      get "/" `shouldRespondWith` 200
+
+    it "gives 404 when requesting a nonexistent table in this nonexistent schema" $
+      get "/nonexistent_table" `shouldRespondWith` 404
diff --git a/test/spec/Feature/OpenApiSpec.hs b/test/spec/Feature/OpenApiSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/OpenApiSpec.hs
@@ -0,0 +1,615 @@
+module Feature.OpenApiSpec where
+
+import Control.Lens     ((^?))
+import Data.Aeson.Types (Value (..))
+import Network.Wai      (Application)
+import Network.Wai.Test (SResponse (..))
+
+import Data.Aeson.Lens
+import Data.Aeson.QQ
+import Network.HTTP.Types
+import Test.Hspec         hiding (pendingWith)
+import Test.Hspec.Wai
+
+import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
+                                   pgVersion110)
+
+import PostgREST.Version (docsVersion)
+import Protolude         hiding (get)
+import SpecHelper
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion = describe "OpenAPI" $ do
+  it "root path returns a valid openapi spec" $ do
+    validateOpenApiResponse [("Accept", "application/openapi+json")]
+    request methodHead "/" (acceptHdrs "application/openapi+json") ""
+      `shouldRespondWith` "" { matchStatus  = 200 }
+
+  it "should respond to openapi request on none root path with 415" $
+    request methodGet "/items"
+            (acceptHdrs "application/openapi+json") ""
+      `shouldRespondWith` 415
+
+  it "includes postgrest.org current version api docs" $ do
+    r <- simpleBody <$> get "/"
+
+    let docsUrl = r ^? key "externalDocs" . key "url"
+
+    liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
+
+  describe "table" $ do
+
+    it "includes paths to tables" $ do
+      r <- simpleBody <$> get "/"
+
+      let method s = key "paths" . key "/child_entities" . key s
+          childGetSummary = r ^? method "get" . key "summary"
+          childGetDescription = r ^? method "get" . key "description"
+          getParameters = r ^? method "get" . key "parameters"
+          postParameters = r ^? method "post" . key "parameters"
+          postResponse = r ^? method "post" . key "responses" . key "201" . key "description"
+          patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"
+          deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"
+
+      let grandChildGet s = key "paths" . key "/grandchild_entities" . key "get" . key s
+          grandChildGetSummary = r ^? grandChildGet "summary"
+          grandChildGetDescription = r ^? grandChildGet "description"
+
+      liftIO $ do
+
+        childGetSummary `shouldBe` Just "child_entities comment"
+
+        childGetDescription `shouldBe` Nothing
+
+        grandChildGetSummary `shouldBe` Just "grandchild_entities summary"
+
+        grandChildGetDescription `shouldBe` Just "grandchild_entities description\nthat spans\nmultiple lines"
+
+        getParameters `shouldBe` Just
+          [aesonQQ|
+            [
+              { "$ref": "#/parameters/rowFilter.child_entities.id" },
+              { "$ref": "#/parameters/rowFilter.child_entities.name" },
+              { "$ref": "#/parameters/rowFilter.child_entities.parent_id" },
+              { "$ref": "#/parameters/select" },
+              { "$ref": "#/parameters/order" },
+              { "$ref": "#/parameters/range" },
+              { "$ref": "#/parameters/rangeUnit" },
+              { "$ref": "#/parameters/offset" },
+              { "$ref": "#/parameters/limit" },
+              { "$ref": "#/parameters/preferCount" }
+            ]
+          |]
+
+        postParameters `shouldBe` Just
+          [aesonQQ|
+            [
+              { "$ref": "#/parameters/body.child_entities" },
+              { "$ref": "#/parameters/select" },
+              { "$ref": "#/parameters/preferReturn" }
+            ]
+          |]
+
+        postResponse `shouldBe` Just "Created"
+
+        patchResponse `shouldBe` Just "No Content"
+
+        deleteResponse `shouldBe` Just "No Content"
+
+    it "includes an array type for GET responses" $ do
+      r <- simpleBody <$> get "/"
+
+      let childGetSchema = r ^? key "paths"
+            . key "/child_entities"
+            . key "get"
+            . key "responses"
+            . key "200"
+            . key "schema"
+
+      liftIO $
+        childGetSchema `shouldBe` Just
+          [aesonQQ|
+            {
+              "items": {
+                "$ref": "#/definitions/child_entities"
+              },
+              "type": "array"
+            }
+          |]
+
+    it "includes definitions to tables" $ do
+      r <- simpleBody <$> get "/"
+
+      let def = r ^? key "definitions" . key "child_entities"
+
+      liftIO $
+
+        def `shouldBe` Just
+          [aesonQQ|
+            {
+              "type": "object",
+              "description": "child_entities comment",
+              "properties": {
+                "id": {
+                  "description": "child_entities id comment\n\nNote:\nThis is a Primary Key.<pk/>",
+                  "format": "integer",
+                  "type": "integer"
+                },
+                "name": {
+                  "description": "child_entities name comment. Can be longer than sixty-three characters long",
+                  "format": "text",
+                  "type": "string"
+                },
+                "parent_id": {
+                  "description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' column='id'/>",
+                  "format": "integer",
+                  "type": "integer"
+                }
+              },
+              "required": [
+                "id"
+              ]
+            }
+          |]
+
+    it "doesn't include privileged table for anonymous" $ do
+      r <- simpleBody <$> get "/"
+      let tablePath = r ^? key "paths" . key "/authors_only"
+
+      liftIO $ tablePath `shouldBe` Nothing
+
+    it "includes table if user has permission" $ do
+      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
+      r <- simpleBody <$> request methodGet "/" [auth] ""
+      let tableTag = r ^? key "paths" . key "/authors_only"
+                    . key "post"  . key "tags"
+                    . nth 0
+      liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]
+
+  describe "Foreign table" $
+
+    it "includes foreign table properties" $ do
+      r <- simpleBody <$> get "/"
+
+      let method s = key "paths" . key "/projects_dump" . key s
+          getSummary = r ^? method "get" . key "summary"
+          getDescription = r ^? method "get" . key "description"
+          getParameters = r ^? method "get" . key "parameters"
+
+      liftIO $ do
+
+        getSummary `shouldBe` Just "A temporary projects dump"
+
+        getDescription `shouldBe` Just "Just a test for foreign tables"
+
+        getParameters `shouldBe` Just
+          [aesonQQ|
+            [
+              { "$ref": "#/parameters/rowFilter.projects_dump.id" },
+              { "$ref": "#/parameters/rowFilter.projects_dump.name" },
+              { "$ref": "#/parameters/rowFilter.projects_dump.client_id" },
+              { "$ref": "#/parameters/select" },
+              { "$ref": "#/parameters/order" },
+              { "$ref": "#/parameters/range" },
+              { "$ref": "#/parameters/rangeUnit" },
+              { "$ref": "#/parameters/offset" },
+              { "$ref": "#/parameters/limit" },
+              { "$ref": "#/parameters/preferCount" }
+            ]
+          |]
+
+  when (actualPgVersion >= pgVersion100) $ do
+    describe "Partitioned table" $
+
+      it "includes partitioned table properties" $ do
+        r <- simpleBody <$> get "/"
+
+        let method s = key "paths" . key "/car_models" . key s
+            getSummary = r ^? method "get" . key "summary"
+            getDescription = r ^? method "get" . key "description"
+            getParameterName = r ^? method "get" . key "parameters" . nth 0 . key "$ref"
+            getParameterYear = r ^? method "get" . key "parameters" . nth 1 . key "$ref"
+            getParameterRef = r ^? method "get" . key "parameters" . nth 2 . key "$ref"
+
+        liftIO $ do
+
+          getSummary `shouldBe` Just "A partitioned table"
+
+          getDescription `shouldBe` Just "A test for partitioned tables"
+
+          getParameterName `shouldBe` Just "#/parameters/rowFilter.car_models.name"
+
+          getParameterYear `shouldBe` Just "#/parameters/rowFilter.car_models.year"
+
+          when (actualPgVersion >= pgVersion110) $
+            getParameterRef `shouldBe` Just "#/parameters/rowFilter.car_models.car_brand_name"
+
+  describe "Materialized view" $
+
+    it "includes materialized view properties" $ do
+      r <- simpleBody <$> get "/"
+
+      let method s = key "paths" . key "/materialized_projects" . key s
+          summary = r ^? method "get" . key "summary"
+          description = r ^? method "get" . key "description"
+          parameters = r ^? method "get" . key "parameters"
+
+      liftIO $ do
+
+        summary `shouldBe` Just "A materialized view for projects"
+
+        description `shouldBe` Just "Just a test for materialized views"
+
+        parameters `shouldBe` Just
+          [aesonQQ|
+            [
+              { "$ref": "#/parameters/rowFilter.materialized_projects.id" },
+              { "$ref": "#/parameters/rowFilter.materialized_projects.name" },
+              { "$ref": "#/parameters/rowFilter.materialized_projects.client_id" },
+              { "$ref": "#/parameters/select" },
+              { "$ref": "#/parameters/order" },
+              { "$ref": "#/parameters/range" },
+              { "$ref": "#/parameters/rangeUnit" },
+              { "$ref": "#/parameters/offset" },
+              { "$ref": "#/parameters/limit" },
+              { "$ref": "#/parameters/preferCount" }
+            ]
+          |]
+
+  describe "VIEW that has a source FK based on a UNIQUE key" $
+
+    it "includes fk description" $ do
+      r <- simpleBody <$> get "/"
+
+      let referralLink = r ^? key "definitions" . key "referrals" . key "properties" . key "link"
+
+      liftIO $
+        referralLink `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "integer",
+              "type": "integer",
+              "description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' column='link'/>"
+            }
+          |]
+
+  describe "PostgreSQL to Swagger Type Mapping" $ do
+
+    it "character varying to string" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character_varying"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "character varying",
+              "type": "string"
+            }
+          |]
+    it "character(1) to string" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "maxLength": 1,
+              "format": "character",
+              "type": "string"
+            }
+          |]
+
+    it "text to string" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "text",
+              "type": "string"
+            }
+          |]
+
+    it "boolean to boolean" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_boolean"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "boolean",
+              "type": "boolean"
+            }
+          |]
+
+    it "smallint to integer" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_smallint"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "smallint",
+              "type": "integer"
+            }
+          |]
+
+    it "integer to integer" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_integer"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "integer",
+              "type": "integer"
+            }
+          |]
+
+    it "bigint to integer" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "bigint",
+              "type": "integer"
+            }
+          |]
+
+    it "numeric to number" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "numeric",
+              "type": "number"
+            }
+          |]
+
+    it "real to number" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_real"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "real",
+              "type": "number"
+            }
+          |]
+
+    it "double_precision to number" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_double_precision"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "double precision",
+              "type": "number"
+            }
+          |]
+
+    it "json to any" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_json"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "json"
+            }
+          |]
+
+    it "jsonb to any" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_jsonb"
+
+      liftIO $
+
+        types `shouldBe` Just
+          [aesonQQ|
+            {
+              "format": "jsonb"
+            }
+          |]
+
+  describe "Detects default values" $ do
+
+    it "text" $ do
+      r <- simpleBody <$> get "/"
+
+      let defaultValue = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "text" . key "default"
+
+      liftIO $
+
+        defaultValue `shouldBe` Just "default"
+
+    it "boolean" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "boolean" . key "default"
+
+      liftIO $
+
+        types `shouldBe` Just (Bool False)
+
+    it "integer" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "integer" . key "default"
+
+      liftIO $
+
+        types `shouldBe` Just (Number 42)
+
+    it "numeric" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "numeric" . key "default"
+
+      liftIO $
+
+        types `shouldBe` Just (Number 42.2)
+
+    it "date" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "date" . key "default"
+
+      liftIO $
+
+        types `shouldBe` Just "1900-01-01"
+
+    it "time" $ do
+      r <- simpleBody <$> get "/"
+
+      let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "time" . key "default"
+
+      liftIO $
+
+        types `shouldBe` Just "13:00:00"
+
+  describe "RPC" $ do
+
+    it "includes function summary/description and body schema for arguments" $ do
+      r <- simpleBody <$> get "/"
+
+      let method s = key "paths" . key "/rpc/varied_arguments" . key s
+          args = r ^? method "post" . key "parameters" . nth 0 . key "schema"
+          summary = r ^? method "post" . key "summary"
+          description = r ^? method "post" . key "description"
+
+      liftIO $ do
+
+        summary `shouldBe` Just "An RPC function"
+
+        description `shouldBe` Just "Just a test for RPC function arguments"
+
+        args `shouldBe` Just
+          [aesonQQ|
+            {
+              "required": [
+                "double",
+                "varchar",
+                "boolean",
+                "date",
+                "money",
+                "enum",
+                "arr"
+              ],
+              "properties": {
+                "double": {
+                  "format": "double precision",
+                  "type": "number"
+                },
+                "varchar": {
+                  "format": "character varying",
+                  "type": "string"
+                },
+                "boolean": {
+                  "format": "boolean",
+                  "type": "boolean"
+                },
+                "date": {
+                  "format": "date",
+                  "type": "string"
+                },
+                "money": {
+                  "format": "money",
+                  "type": "string"
+                },
+                "enum": {
+                  "format": "enum_menagerie_type",
+                  "type": "string"
+                },
+                "arr": {
+                  "format": "text[]",
+                  "type": "string"
+                },
+                "integer": {
+                  "format": "integer",
+                  "type": "integer"
+                },
+                "json": {
+                  "format": "json"
+                },
+                "jsonb": {
+                  "format": "jsonb"
+                }
+              },
+              "type": "object",
+              "description": "An RPC function\n\nJust a test for RPC function arguments"
+            }
+          |]
+
+    it "doesn't include privileged function for anonymous" $ do
+      r <- simpleBody <$> get "/"
+      let funcPath = r ^? key "paths" . key "/rpc/privileged_hello"
+
+      liftIO $ funcPath `shouldBe` Nothing
+
+    it "includes function if user has permission" $ do
+      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
+      r <- simpleBody <$> request methodGet "/" [auth] ""
+      let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"
+                    . key "post"  . key "tags"
+                    . nth 0
+
+      liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]
+
+    it "doesn't include OUT params of function as required parameters" $ do
+      r <- simpleBody <$> get "/"
+      let params = r ^? key "paths" . key "/rpc/many_out_params"
+                      . key "post" . key "parameters" .  nth 0
+                      . key "schema". key "required"
+
+      liftIO $ params `shouldBe` Nothing
+
+    it "includes INOUT params(with no DEFAULT) of function as required parameters" $ do
+      r <- simpleBody <$> get "/"
+      let params = r ^? key "paths" . key "/rpc/many_inout_params"
+                      . key "post" . key "parameters" .  nth 0
+                      . key "schema". key "required"
+
+      liftIO $ params `shouldBe` Just [aesonQQ|["num", "str"]|]
+
diff --git a/test/spec/Feature/OptionsSpec.hs b/test/spec/Feature/OptionsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/OptionsSpec.hs
@@ -0,0 +1,87 @@
+module Feature.OptionsSpec where
+
+import Network.Wai      (Application)
+import Network.Wai.Test (SResponse (..))
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+
+import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
+                                   pgVersion110)
+
+import Protolude
+import SpecHelper
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion = describe "Allow header" $ do
+  context "a table" $ do
+    it "includes read/write verbs for writeable table" $ do
+      r <- request methodOptions "/items" [] ""
+      liftIO $
+        simpleHeaders r `shouldSatisfy`
+          matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
+
+  when (actualPgVersion >= pgVersion100) $
+    context "a partitioned table" $ do
+      it "includes read/write verbs for writeable partitioned tables" $ do
+        r <- request methodOptions "/car_models" [] ""
+        liftIO $
+          simpleHeaders r `shouldSatisfy`
+            matchHeader "Allow" (
+              if actualPgVersion >= pgVersion110 then
+                "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
+              else
+                "OPTIONS,GET,HEAD,POST,PATCH,DELETE"
+            )
+
+  context "a view" $ do
+    context "auto updatable" $ do
+      it "includes read/write verbs for auto updatable views with pk" $ do
+        r <- request methodOptions "/projects_auto_updatable_view_with_pk" [] ""
+        liftIO $
+          simpleHeaders r `shouldSatisfy`
+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
+
+      it "includes read/write verbs for auto updatable views without pk" $ do
+        r <- request methodOptions "/projects_auto_updatable_view_without_pk" [] ""
+        liftIO $
+          simpleHeaders r `shouldSatisfy`
+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PATCH,DELETE"
+
+    context "non auto updatable" $ do
+      it "includes read verbs for non auto updatable views" $ do
+        r <- request methodOptions "/projects_view_without_triggers" [] ""
+        liftIO $
+          simpleHeaders r `shouldSatisfy`
+            matchHeader "Allow" "OPTIONS,GET,HEAD"
+
+      it "includes read/write verbs for insertable, updatable and deletable views with pk" $ do
+        r <- request methodOptions "/projects_view_with_all_triggers_with_pk" [] ""
+        liftIO $
+          simpleHeaders r `shouldSatisfy`
+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
+
+      it "includes read/write verbs for insertable, updatable and deletable views without pk" $ do
+        r <- request methodOptions "/projects_view_with_all_triggers_without_pk" [] ""
+        liftIO $
+          simpleHeaders r `shouldSatisfy`
+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PATCH,DELETE"
+
+      it "includes read and insert verbs for insertable views" $ do
+        r <- request methodOptions "/projects_view_with_insert_trigger" [] ""
+        liftIO $
+          simpleHeaders r `shouldSatisfy`
+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST"
+
+      it "includes read and update verbs for updatable views" $ do
+        r <- request methodOptions "/projects_view_with_update_trigger" [] ""
+        liftIO $
+          simpleHeaders r `shouldSatisfy`
+            matchHeader "Allow" "OPTIONS,GET,HEAD,PATCH"
+
+      it "includes read and delete verbs for deletable views" $ do
+        r <- request methodOptions "/projects_view_with_delete_trigger" [] ""
+        liftIO $
+          simpleHeaders r `shouldSatisfy`
+            matchHeader "Allow" "OPTIONS,GET,HEAD,DELETE"
diff --git a/test/spec/Feature/ProxySpec.hs b/test/spec/Feature/ProxySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/ProxySpec.hs
@@ -0,0 +1,13 @@
+module Feature.ProxySpec where
+
+import Network.Wai (Application)
+import Test.Hspec  hiding (pendingWith)
+
+import Protolude
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "GET / with proxy" $
+    it "returns a valid openapi spec with proxy" $
+      validateOpenApiResponse [("Accept", "application/openapi+json")]
diff --git a/test/spec/Feature/QueryLimitedSpec.hs b/test/spec/Feature/QueryLimitedSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/QueryLimitedSpec.hs
@@ -0,0 +1,69 @@
+module Feature.QueryLimitedSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "Requesting many items with server limits(max-rows) enabled" $ do
+    it "restricts results" $
+      get "/items?order=id"
+        `shouldRespondWith`
+          [json| [{"id":1},{"id":2}] |]
+          { matchHeaders = ["Content-Range" <:> "0-1/*"] }
+
+    it "respects additional client limiting" $ do
+      request methodGet  "/items"
+          (rangeHdrs $ ByteRangeFromTo 0 0)
+          ""
+        `shouldRespondWith`
+          [json| [{"id":1}] |]
+          { matchHeaders = ["Content-Range" <:> "0-0/*"] }
+
+    it "works on all levels" $
+      get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
+        `shouldRespondWith`
+          [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
+          { matchHeaders = ["Content-Range" <:> "0-1/*"] }
+
+    it "succeeds in getting parent embeds despite the limit, see #647" $
+      get "/tasks?select=id,project:projects(id)&id=gt.5"
+        `shouldRespondWith`
+          [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
+          { matchHeaders = ["Content-Range" <:> "0-1/*"] }
+
+    it "can offset the parent embed, being consistent with the other embed types" $
+      get "/tasks?select=id,project:projects(id)&id=gt.5&project.offset=1"
+        `shouldRespondWith`
+          [json|[{"id":6,"project":null}, {"id":7,"project":null}]|]
+          { matchHeaders = ["Content-Range" <:> "0-1/*"] }
+
+    context "count=estimated" $ do
+      it "uses the query planner guess when query rows > maxRows" $
+        request methodHead "/getallprojects_view" [("Prefer", "count=estimated")] ""
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 206
+            , matchHeaders = ["Content-Range" <:> "0-1/2019"]
+            }
+
+      it "gives exact count when query rows <= maxRows" $
+        request methodHead "/getallprojects_view?id=lt.3" [("Prefer", "count=estimated")] ""
+          `shouldRespondWith`
+            ""
+            { matchHeaders = ["Content-Range" <:> "0-1/2"] }
+
+      it "only uses the query planner guess if it's indeed greater than the exact count" $
+        request methodHead "/get_projects_above_view" [("Prefer", "count=estimated")] ""
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 206
+            , matchHeaders = ["Content-Range" <:> "0-1/3"]
+            }
diff --git a/test/spec/Feature/QuerySpec.hs b/test/spec/Feature/QuerySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/QuerySpec.hs
@@ -0,0 +1,1125 @@
+module Feature.QuerySpec where
+
+import Network.Wai      (Application)
+import Network.Wai.Test (SResponse (simpleHeaders))
+
+import Network.HTTP.Types
+import Test.Hspec          hiding (pendingWith)
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
+                                   pgVersion112, pgVersion121)
+import Protolude                  hiding (get)
+import SpecHelper
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion = do
+
+  describe "Querying a table with a column called count" $
+    it "should not confuse count column with pg_catalog.count aggregate" $
+      get "/has_count_column" `shouldRespondWith` 200
+
+  describe "Querying a table with a column called t" $
+    it "should not conflict with internal postgrest table alias" $
+      get "/clashing_column?select=t" `shouldRespondWith` 200
+
+  describe "Querying a nonexistent table" $
+    it "causes a 404" $
+      get "/faketable" `shouldRespondWith` 404
+
+  describe "Filtering response" $ do
+    it "matches with equality" $
+      get "/items?id=eq.5"
+        `shouldRespondWith` [json| [{"id":5}] |]
+        { matchHeaders = ["Content-Range" <:> "0-0/*"] }
+
+    it "matches with equality using not operator" $
+      get "/items?id=not.eq.5&order=id"
+        `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
+        { matchHeaders = ["Content-Range" <:> "0-13/*"] }
+
+    it "matches with more than one condition using not operator" $
+      get "/simple_pk?k=like.*yx&extra=not.eq.u" `shouldRespondWith` "[]"
+
+    it "matches with inequality using not operator" $ do
+      get "/items?id=not.lt.14&order=id.asc"
+        `shouldRespondWith` [json| [{"id":14},{"id":15}] |]
+        { matchHeaders = ["Content-Range" <:> "0-1/*"] }
+      get "/items?id=not.gt.2&order=id.asc"
+        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
+        { matchHeaders = ["Content-Range" <:> "0-1/*"] }
+
+    it "matches items IN" $
+      get "/items?id=in.(1,3,5)"
+        `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
+        { matchHeaders = ["Content-Range" <:> "0-2/*"] }
+
+    it "matches items NOT IN using not operator" $
+      get "/items?id=not.in.(2,4,6,7,8,9,10,11,12,13,14,15)"
+        `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
+        { matchHeaders = ["Content-Range" <:> "0-2/*"] }
+
+    it "matches nulls using not operator" $
+      get "/no_pk?a=not.is.null" `shouldRespondWith`
+        [json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "matches nulls in varchar and numeric fields alike" $ do
+      get "/no_pk?a=is.null" `shouldRespondWith`
+        [json| [{"a": null, "b": null}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+      get "/nullable_integer?a=is.null" `shouldRespondWith` [json|[{"a":null}]|]
+
+    it "matches with trilean values" $ do
+      get "/chores?done=is.true" `shouldRespondWith`
+        [json| [{"id": 1, "name": "take out the garbage", "done": true }] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+      get "/chores?done=is.false" `shouldRespondWith`
+        [json| [{"id": 2, "name": "do the laundry", "done": false }] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+      get "/chores?done=is.unknown" `shouldRespondWith`
+        [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "matches with trilean values in upper or mixed case" $ do
+      get "/chores?done=is.NULL" `shouldRespondWith`
+        [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+      get "/chores?done=is.TRUE" `shouldRespondWith`
+        [json| [{"id": 1, "name": "take out the garbage", "done": true }] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+      get "/chores?done=is.FAlSe" `shouldRespondWith`
+        [json| [{"id": 2, "name": "do the laundry", "done": false }] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+      get "/chores?done=is.UnKnOwN" `shouldRespondWith`
+        [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "fails if 'is' used and there's no null or trilean value" $ do
+      get "/chores?done=is.nil" `shouldRespondWith` 400
+      get "/chores?done=is.ok"  `shouldRespondWith` 400
+
+    it "matches with like" $ do
+      get "/simple_pk?k=like.*yx" `shouldRespondWith`
+        [json|[{"k":"xyyx","extra":"u"}]|]
+      get "/simple_pk?k=like.xy*" `shouldRespondWith`
+        [json|[{"k":"xyyx","extra":"u"}]|]
+      get "/simple_pk?k=like.*YY*" `shouldRespondWith`
+        [json|[{"k":"xYYx","extra":"v"}]|]
+
+    it "matches with like using not operator" $
+      get "/simple_pk?k=not.like.*yx" `shouldRespondWith`
+        [json|[{"k":"xYYx","extra":"v"}]|]
+
+    it "matches with ilike" $ do
+      get "/simple_pk?k=ilike.xy*&order=extra.asc" `shouldRespondWith`
+        [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith`
+        [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "matches with ilike using not operator" $
+      get "/simple_pk?k=not.ilike.xy*&order=extra.asc" `shouldRespondWith` "[]"
+
+    describe "Full text search operator" $ do
+      it "finds matches with to_tsquery" $
+        get "/tsearch?text_search_vector=fts.impossible" `shouldRespondWith`
+          [json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "can use lexeme boolean operators(&=%26, |=%7C, !) in to_tsquery" $ do
+        get "/tsearch?text_search_vector=fts.fun%26possible" `shouldRespondWith`
+          [json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/tsearch?text_search_vector=fts.impossible%7Cpossible" `shouldRespondWith`
+          [json| [
+          {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
+          {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/tsearch?text_search_vector=fts.fun%26!possible" `shouldRespondWith`
+          [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "finds matches with plainto_tsquery" $
+        get "/tsearch?text_search_vector=plfts.The%20Fat%20Rats" `shouldRespondWith`
+          [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      when (actualPgVersion >= pgVersion112) $ do
+        it "finds matches with websearch_to_tsquery" $
+            get "/tsearch?text_search_vector=wfts.The%20Fat%20Rats" `shouldRespondWith`
+                [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
+                { matchHeaders = [matchContentTypeJson] }
+
+        it "can use boolean operators(and, or, -) in websearch_to_tsquery" $ do
+          get "/tsearch?text_search_vector=wfts.fun%20and%20possible"
+            `shouldRespondWith`
+              [json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]
+              { matchHeaders = [matchContentTypeJson] }
+          get "/tsearch?text_search_vector=wfts.impossible%20or%20possible"
+            `shouldRespondWith`
+              [json| [
+                {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
+                {"text_search_vector": "'also':2 'fun':3 'possibl':8"}]
+                  |]
+              { matchHeaders = [matchContentTypeJson] }
+          get "/tsearch?text_search_vector=wfts.fun%20and%20-possible"
+            `shouldRespondWith`
+              [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]
+              { matchHeaders = [matchContentTypeJson] }
+
+      it "finds matches with different dictionaries" $ do
+        get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`
+          [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/tsearch?text_search_vector=plfts(french).amusant%20impossible" `shouldRespondWith`
+          [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+        when (actualPgVersion >= pgVersion112) $
+            get "/tsearch?text_search_vector=wfts(french).amusant%20impossible"
+                `shouldRespondWith`
+                  [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
+                  { matchHeaders = [matchContentTypeJson] }
+
+      it "can be negated with not operator" $ do
+        get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`
+          [json| [
+            {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
+            {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/tsearch?text_search_vector=not.fts(english).impossible%7Cfat%7Cfun" `shouldRespondWith`
+          [json| [
+            {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
+            {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/tsearch?text_search_vector=not.plfts.The%20Fat%20Rats" `shouldRespondWith`
+          [json| [
+            {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
+            {"text_search_vector": "'also':2 'fun':3 'possibl':8"},
+            {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
+            {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        when (actualPgVersion >= pgVersion112) $
+            get "/tsearch?text_search_vector=not.wfts(english).impossible%20or%20fat%20or%20fun"
+                `shouldRespondWith`
+                  [json| [
+                    {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
+                    {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
+                  { matchHeaders = [matchContentTypeJson] }
+
+      context "Use of the phraseto_tsquery function" $ do
+        it "finds matches" $
+          get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`
+            [json| [{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "finds matches with different dictionaries" $
+          get "/tsearch?text_search_vector=phfts(german).Art%20Spass" `shouldRespondWith`
+            [json| [{"text_search_vector": "'art':4 'spass':5 'unmog':7" }] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can be negated with not operator" $
+          get "/tsearch?text_search_vector=not.phfts(english).The%20Fat%20Cats" `shouldRespondWith`
+            [json| [
+              {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
+              {"text_search_vector": "'also':2 'fun':3 'possibl':8"},
+              {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
+              {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can be used with or query param" $
+          get "/tsearch?or=(text_search_vector.phfts(german).Art%20Spass, text_search_vector.phfts(french).amusant, text_search_vector.fts(english).impossible)" `shouldRespondWith`
+            [json|[
+              {"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
+              {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
+              {"text_search_vector": "'art':4 'spass':5 'unmog':7"}
+            ]|] { matchHeaders = [matchContentTypeJson] }
+
+    it "matches with computed column" $
+      get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
+        [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "order by computed column" $
+      get "/items?order=anti_id.desc" `shouldRespondWith`
+        [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "cannot access a computed column that is outside of the config schema" $
+      get "/items?always_false=is.false" `shouldRespondWith` 400
+
+    it "matches filtering nested items 2" $
+      get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*" `shouldRespondWith`
+        [json| {
+          "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
+          "message":"Could not find a relationship between projects and tasks2 in the schema cache"}|]
+        { matchStatus  = 400
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+    it "matches filtering nested items" $
+      get "/clients?select=id,projects(id,tasks(id,name))&projects.tasks.name=like.Design*" `shouldRespondWith`
+        [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "matches with cs operator" $
+      get "/complex_items?select=id&arr_data=cs.{2}" `shouldRespondWith`
+        [json|[{"id":2},{"id":3}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "matches with cd operator" $
+      get "/complex_items?select=id&arr_data=cd.{1,2,4}" `shouldRespondWith`
+        [json|[{"id":1},{"id":2}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+  describe "Shaping response with select parameter" $ do
+    it "selectStar works in absense of parameter" $
+      get "/complex_items?id=eq.3" `shouldRespondWith`
+        [json|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]
+
+    it "dash `-` in column names is accepted" $
+      get "/complex_items?id=eq.3&select=id,field-with_sep" `shouldRespondWith`
+        [json|[{"id":3,"field-with_sep":1}]|]
+
+    it "one simple column" $
+      get "/complex_items?select=id" `shouldRespondWith`
+        [json| [{"id":1},{"id":2},{"id":3}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "rename simple column" $
+      get "/complex_items?id=eq.1&select=myId:id" `shouldRespondWith`
+        [json| [{"myId":1}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "one simple column with casting (text)" $
+      get "/complex_items?select=id::text" `shouldRespondWith`
+        [json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "rename simple column with casting" $
+      get "/complex_items?id=eq.1&select=myId:id::text" `shouldRespondWith`
+        [json| [{"myId":"1"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "json column" $
+      get "/complex_items?id=eq.1&select=settings" `shouldRespondWith`
+        [json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "fails on bad casting (wrong cast type)" $
+      get "/complex_items?select=id::fakecolumntype"
+        `shouldRespondWith` [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]
+        { matchStatus  = 400
+        , matchHeaders = []
+        }
+
+    it "requesting parents and children" $
+      get "/projects?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`
+        [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting parent and renaming primary key" $
+      get "/projects?select=name,client:clients(clientId:id,name)" `shouldRespondWith`
+        [json|[
+          {"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},
+          {"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},
+          {"name":"IOS","client":{"name": "Apple", "clientId": 2}},
+          {"name":"OSX","client":{"name": "Apple", "clientId": 2}},
+          {"name":"Orphan","client":null}
+        ]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting parent and specifying/renaming one key of the composite primary key" $ do
+      get "/comments?select=*,users_tasks(userId:user_id)" `shouldRespondWith`
+        [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"userId": 2}}]|]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/comments?select=*,users_tasks(taskId:task_id)" `shouldRespondWith`
+        [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting parents and children while renaming them" $
+      get "/projects?id=eq.1&select=myId:id, name, project_client:clients(*), project_tasks:tasks(id, name)" `shouldRespondWith`
+        [json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting parents and filtering parent columns" $
+      get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`
+        [json|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
+
+    it "rows with missing parents are included" $
+      get "/projects?id=in.(1,5)&select=id,clients(id)" `shouldRespondWith`
+        [json|[{"id":1,"clients":{"id":1}},{"id":5,"clients":null}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "rows with no children return [] instead of null" $
+      get "/projects?id=in.(5)&select=id,tasks(id)" `shouldRespondWith`
+        [json|[{"id":5,"tasks":[]}]|]
+
+    it "requesting children 2 levels" $
+      get "/clients?id=eq.1&select=id,projects(id,tasks(id))" `shouldRespondWith`
+        [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting many<->many relation" $
+      get "/tasks?select=id,users(id)" `shouldRespondWith`
+        [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting many<->many relation with rename" $
+      get "/tasks?id=eq.1&select=id,theUsers:users(id)" `shouldRespondWith`
+        [json|[{"id":1,"theUsers":[{"id":1},{"id":3}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting many<->many relation reverse" $
+      get "/users?select=id,tasks(id)" `shouldRespondWith`
+        [json|[{"id":1,"tasks":[{"id":1},{"id":2},{"id":3},{"id":4}]},{"id":2,"tasks":[{"id":5},{"id":6},{"id":7}]},{"id":3,"tasks":[{"id":1},{"id":5}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting many<->many relation using composite key" $
+      get "/files?filename=eq.autoexec.bat&project_id=eq.1&select=filename,users_tasks(user_id,task_id)" `shouldRespondWith`
+        [json|[{"filename":"autoexec.bat","users_tasks":[{"user_id":1,"task_id":1},{"user_id":3,"task_id":1}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting data using many<->many relation defined by composite keys" $
+      get "/users_tasks?user_id=eq.1&task_id=eq.1&select=user_id,files(filename,content)" `shouldRespondWith`
+        [json|[{"user_id":1,"files":[{"filename":"command.com","content":"#include <unix.h>"},{"filename":"autoexec.bat","content":"@ECHO OFF"},{"filename":"README.md","content":"# make $$$!"}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting data using many<->many (composite keys) relation using hint" $
+      get "/users_tasks?user_id=eq.1&task_id=eq.1&select=user_id,files!touched_files(filename,content)" `shouldRespondWith`
+        [json|[{"user_id":1,"files":[{"filename":"command.com","content":"#include <unix.h>"},{"filename":"autoexec.bat","content":"@ECHO OFF"},{"filename":"README.md","content":"# make $$$!"}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "requesting children with composite key" $
+      get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments(content)" `shouldRespondWith`
+        [json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    describe "computed columns" $ do
+      it "computed column on table" $
+        get "/items?id=eq.1&select=id,always_true" `shouldRespondWith`
+          [json|[{"id":1,"always_true":true}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "computed column on rpc" $
+        get "/rpc/search?id=1&select=id,always_true" `shouldRespondWith`
+          [json|[{"id":1,"always_true":true}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "overloaded computed columns on both tables" $ do
+        get "/items?id=eq.1&select=id,computed_overload" `shouldRespondWith`
+          [json|[{"id":1,"computed_overload":true}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/items2?id=eq.1&select=id,computed_overload" `shouldRespondWith`
+          [json|[{"id":1,"computed_overload":true}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "overloaded computed column on rpc" $
+        get "/rpc/search?id=1&select=id,computed_overload" `shouldRespondWith`
+          [json|[{"id":1,"computed_overload":true}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+    when (actualPgVersion >= pgVersion110) $ do
+      describe "partitioned tables embedding" $ do
+        it "can request a table as parent from a partitioned table" $
+          get "/car_models?name=in.(DeLorean,Murcielago)&select=name,year,car_brands(name)&order=name.asc" `shouldRespondWith`
+            [json|
+              [{"name":"DeLorean","year":1981,"car_brands":{"name":"DMC"}},
+               {"name":"Murcielago","year":2001,"car_brands":{"name":"Lamborghini"}}] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can request partitioned tables as children from a table" $
+          get "/car_brands?select=name,car_models(name,year)&order=name.asc&car_models.order=name.asc" `shouldRespondWith`
+            [json|
+              [{"name":"DMC","car_models":[{"name":"DeLorean","year":1981}]},
+               {"name":"Ferrari","car_models":[{"name":"F310-B","year":1997}]},
+               {"name":"Lamborghini","car_models":[{"name":"Murcielago","year":2001},{"name":"Veneno","year":2013}]}] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+        when (actualPgVersion >= pgVersion121) $ do
+          it "can request tables as children from a partitioned table" $
+            get "/car_models?name=in.(DeLorean,F310-B)&select=name,year,car_racers(name)&order=name.asc" `shouldRespondWith`
+              [json|
+                [{"name":"DeLorean","year":1981,"car_racers":[]},
+                 {"name":"F310-B","year":1997,"car_racers":[{"name":"Michael Schumacher"}]}] |]
+              { matchHeaders = [matchContentTypeJson] }
+
+          it "can request a partitioned table as parent from a table" $
+            get "/car_racers?select=name,car_models(name,year)&order=name.asc" `shouldRespondWith`
+              [json|
+                [{"name":"Alain Prost","car_models":null},
+                 {"name":"Michael Schumacher","car_models":{"name":"F310-B","year":1997}}] |]
+              { matchHeaders = [matchContentTypeJson] }
+
+          it "can request partitioned tables as children from a partitioned table" $
+            get "/car_models?name=in.(DeLorean,Murcielago,Veneno)&select=name,year,car_model_sales(date,quantity)&order=name.asc" `shouldRespondWith`
+              [json|
+                [{"name":"DeLorean","year":1981,"car_model_sales":[{"date":"2021-01-14","quantity":7},{"date":"2021-01-15","quantity":9}]},
+                 {"name":"Murcielago","year":2001,"car_model_sales":[{"date":"2021-02-11","quantity":1},{"date":"2021-02-12","quantity":3}]},
+                 {"name":"Veneno","year":2013,"car_model_sales":[]}] |]
+              { matchHeaders = [matchContentTypeJson] }
+
+          it "can request a partitioned table as parent from a partitioned table" $ do
+            get "/car_model_sales?date=in.(2021-01-15,2021-02-11)&select=date,quantity,car_models(name,year)&order=date.asc" `shouldRespondWith`
+              [json|
+                [{"date":"2021-01-15","quantity":9,"car_models":{"name":"DeLorean","year":1981}},
+                 {"date":"2021-02-11","quantity":1,"car_models":{"name":"Murcielago","year":2001}}] |]
+              { matchHeaders = [matchContentTypeJson] }
+
+          it "can request many to many relationships between partitioned tables ignoring the intermediate table partitions" $
+            get "/car_models?select=name,year,car_dealers(name,city)&order=name.asc&limit=4" `shouldRespondWith`
+              [json|
+                [{"name":"DeLorean","year":1981,"car_dealers":[{"name":"Springfield Cars S.A.","city":"Springfield"}]},
+                 {"name":"F310-B","year":1997,"car_dealers":[]},
+                 {"name":"Murcielago","year":2001,"car_dealers":[{"name":"The Best Deals S.A.","city":"Franklin"}]},
+                 {"name":"Veneno","year":2013,"car_dealers":[]}] |]
+              { matchStatus  = 200
+              , matchHeaders = [matchContentTypeJson]
+              }
+
+          it "cannot request partitions as children from a partitioned table" $
+            get "/car_models?id=in.(1,2,4)&select=id,name,car_model_sales_202101(id)&order=id.asc" `shouldRespondWith`
+              [json|
+                {"hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
+                 "message":"Could not find a relationship between car_models and car_model_sales_202101 in the schema cache"} |]
+              { matchStatus  = 400
+              , matchHeaders = [matchContentTypeJson]
+              }
+
+          it "cannot request a partitioned table as parent from a partition" $
+            get "/car_model_sales_202101?select=id,name,car_models(id,name)&order=id.asc" `shouldRespondWith`
+              [json|
+                {"hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
+                 "message":"Could not find a relationship between car_model_sales_202101 and car_models in the schema cache"} |]
+              { matchStatus  = 400
+              , matchHeaders = [matchContentTypeJson]
+              }
+
+          it "cannot request a partition as parent from a partitioned table" $
+            get "/car_model_sales?id=in.(1,3,4)&select=id,name,car_models_default(id,name)&order=id.asc" `shouldRespondWith`
+              [json|
+                {"hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
+                 "message":"Could not find a relationship between car_model_sales and car_models_default in the schema cache"} |]
+              { matchStatus  = 400
+              , matchHeaders = [matchContentTypeJson]
+              }
+
+          it "cannot request partitioned tables as children from a partition" $
+            get "/car_models_default?select=id,name,car_model_sales(id,name)&order=id.asc" `shouldRespondWith`
+              [json|
+                {"hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",
+                 "message":"Could not find a relationship between car_models_default and car_model_sales in the schema cache"} |]
+              { matchStatus  = 400
+              , matchHeaders = [matchContentTypeJson]
+              }
+
+    describe "view embedding" $ do
+      it "can detect fk relations through views to tables in the public schema" $
+        get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200
+
+      it "can detect fk relations through materialized views to tables in the public schema" $
+        get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200
+
+      it "can request two parents" $
+        get "/articleStars?select=createdAt,article:articles(id),user:users(name)&limit=1"
+          `shouldRespondWith`
+            [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"id": 1},"user":{"name": "Angela Martin"}}]|]
+
+      it "can detect relations in views from exposed schema that are based on tables in private schema and have columns renames" $
+        get "/articles?id=eq.1&select=id,articleStars(users(*))" `shouldRespondWith`
+          [json|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "works when requesting parents and children on views" $
+        get "/projects_view?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`
+          [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "works when requesting parents and children on views with renamed keys" $
+        get "/projects_view_alt?t_id=eq.1&select=t_id, name, clients(*), tasks(id, name)" `shouldRespondWith`
+          [json|[{"t_id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "detects parent relations when having many views of a private table" $ do
+        get "/books?select=title,author:authors(name)&id=eq.5" `shouldRespondWith`
+          [json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/forties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
+          [json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/fifties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
+          [json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/sixties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
+          [json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "can detect fk relations through multiple views recursively when all views are in api schema" $ do
+        get "/consumers_view_view?select=*,orders_view(*)" `shouldRespondWith` 200
+
+      it "works with views that have subselects" $
+        get "/authors_books_number?select=*,books(title)&id=eq.1" `shouldRespondWith`
+          [json|[ {"id":1, "name":"George Orwell","num_in_forties":1,"num_in_fifties":0,"num_in_sixties":0,"num_in_all_decades":1,
+                   "books":[{"title":"1984"}]} ]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "works with views that have case subselects" $
+        get "/authors_have_book_in_decade?select=*,books(title)&id=eq.3" `shouldRespondWith`
+          [json|[ {"id":3,"name":"Antoine de Saint-Exupéry","has_book_in_forties":true,"has_book_in_fifties":false,"has_book_in_sixties":false,
+                   "books":[{"title":"The Little Prince"}]} ]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "works with views that have subselect in the FROM clause" $
+        get "/forties_and_fifties_books?select=title,first_publisher,author:authors(name)&id=eq.1" `shouldRespondWith`
+          [json|[{"title":"1984","first_publisher":"Secker & Warburg","author":{"name":"George Orwell"}}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "works with views that have subselects in a function call" $
+        get "/authors_have_book_in_decade2?select=*,books(title)&id=eq.3"
+          `shouldRespondWith`
+            [json|[ {"id":3,"name":"Antoine de Saint-Exupéry","has_book_in_forties":true,"has_book_in_fifties":false,
+                     "has_book_in_sixties":false,"books":[{"title":"The Little Prince"}]} ]|]
+
+      it "works with views that have CTE" $
+        get "/odd_years_publications?select=title,publication_year,first_publisher,author:authors(name)&id=in.(1,2,3)" `shouldRespondWith`
+          [json|[
+            {"title":"1984","publication_year":1949,"first_publisher":"Secker & Warburg","author":{"name":"George Orwell"}},
+            {"title":"The Diary of a Young Girl","publication_year":1947,"first_publisher":"Contact Publishing","author":{"name":"Anne Frank"}},
+            {"title":"The Little Prince","publication_year":1947,"first_publisher":"Reynal & Hitchcock","author":{"name":"Antoine de Saint-Exupéry"}} ]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "works when having a capitalized table name and camelCase fk column" $
+        get "/foos?select=*,bars(*)" `shouldRespondWith` 200
+
+      it "works when embedding a view with a table that has a long compound pk" $ do
+        get "/player_view?select=id,contract(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`
+          [json|
+            [{"id":1,"contract":[{"purchase_price":10}]},
+             {"id":3,"contract":[{"purchase_price":30}]},
+             {"id":5,"contract":[{"purchase_price":50}]},
+             {"id":7,"contract":[]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/contract?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`
+          [json|
+            [{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},
+             {"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},
+             {"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "works when embedding a view with a view that referes to a table that has a long compound pk" $ do
+        get "/player_view?select=id,contract_view(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`
+          [json|
+            [{"id":1,"contract_view":[{"purchase_price":10}]},
+             {"id":3,"contract_view":[{"purchase_price":30}]},
+             {"id":5,"contract_view":[{"purchase_price":50}]},
+             {"id":7,"contract_view":[]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/contract_view?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`
+          [json|
+            [{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},
+             {"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},
+             {"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "can embed a view that has group by" $
+        get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`
+          [json|
+            [{"number_of_projects":1,"client":null},
+             {"number_of_projects":2,"client":{"name":"Microsoft"}},
+             {"number_of_projects":2,"client":{"name":"Apple"}}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "can embed a view that has a subselect containing a select in a where" $
+        get "/authors_w_entities?select=name,entities,books(title)&id=eq.1" `shouldRespondWith`
+          [json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+    describe "aliased embeds" $ do
+      it "works with child relation" $
+        get "/space?select=id,zones:zone(id,name),stores:zone(id,name)&zones.zone_type_id=eq.2&stores.zone_type_id=eq.3" `shouldRespondWith`
+          [json|[
+            { "id":1,
+              "zones": [ {"id":1,"name":"zone 1"}, {"id":2,"name":"zone 2"}],
+              "stores": [ {"id":3,"name":"store 3"}, {"id":4,"name":"store 4"}]}
+          ]|] { matchHeaders = [matchContentTypeJson] }
+
+      it "works with many to many relation" $
+        get "/users?select=id,designTasks:tasks(id,name),codeTasks:tasks(id,name)&designTasks.name=like.*Design*&codeTasks.name=like.*Code*" `shouldRespondWith`
+          [json|[
+             { "id":1,
+               "designTasks":[ { "id":1, "name":"Design w7" }, { "id":3, "name":"Design w10" } ],
+               "codeTasks":[ { "id":2, "name":"Code w7" }, { "id":4, "name":"Code w10" } ] },
+             { "id":2,
+               "designTasks":[ { "id":5, "name":"Design IOS" }, { "id":7, "name":"Design OSX" } ],
+               "codeTasks":[ { "id":6, "name":"Code IOS" } ] },
+             { "id":3,
+               "designTasks":[ { "id":1, "name":"Design w7" }, { "id":5, "name":"Design IOS" } ],
+               "codeTasks":[ ] }
+          ]|] { matchHeaders = [matchContentTypeJson] }
+
+      it "works with an aliased child plus non aliased child" $
+        get "/projects?select=id,name,designTasks:tasks(name,users(id,name))&designTasks.name=like.*Design*&designTasks.users.id=in.(1,2)" `shouldRespondWith`
+          [json|[
+            {
+              "id":1, "name":"Windows 7",
+              "designTasks":[ { "name":"Design w7", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },
+            {
+              "id":2, "name":"Windows 10",
+              "designTasks":[ { "name":"Design w10", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },
+            {
+              "id":3, "name":"IOS",
+              "designTasks":[ { "name":"Design IOS", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },
+            {
+              "id":4, "name":"OSX",
+              "designTasks":[ { "name":"Design OSX", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },
+            {
+              "id":5, "name":"Orphan",
+              "designTasks":[ ] }
+          ]|] { matchHeaders = [matchContentTypeJson] }
+
+      it "works with two aliased children embeds plus and/or" $
+        get "/entities?select=id,children:child_entities(id,gChildren:grandchild_entities(id))&children.and=(id.in.(1,2,3))&children.gChildren.or=(id.eq.1,id.eq.2)" `shouldRespondWith`
+          [json|[
+            { "id":1,
+              "children":[
+                {"id":1,"gChildren":[{"id":1}, {"id":2}]},
+                {"id":2,"gChildren":[]}]},
+            { "id":2,
+              "children":[
+                {"id":3,"gChildren":[]}]},
+            { "id":3,"children":[]},
+            { "id":4,"children":[]}
+          ]|] { matchHeaders = [matchContentTypeJson] }
+
+  describe "ordering response" $ do
+    it "by a column asc" $
+      get "/items?id=lte.2&order=id.asc"
+        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
+        { matchStatus  = 200
+        , matchHeaders = ["Content-Range" <:> "0-1/*"]
+        }
+
+
+    it "by a column desc" $
+      get "/items?id=lte.2&order=id.desc"
+        `shouldRespondWith` [json| [{"id":2},{"id":1}] |]
+        { matchStatus  = 200
+        , matchHeaders = ["Content-Range" <:> "0-1/*"]
+        }
+
+    it "by a column with nulls first" $
+      get "/no_pk?order=a.nullsfirst"
+        `shouldRespondWith` [json| [{"a":null,"b":null},
+                              {"a":"1","b":"0"},
+                              {"a":"2","b":"0"}
+                              ] |]
+        { matchStatus = 200
+        , matchHeaders = ["Content-Range" <:> "0-2/*"]
+        }
+
+    it "by a column asc with nulls last" $
+      get "/no_pk?order=a.asc.nullslast"
+        `shouldRespondWith` [json| [{"a":"1","b":"0"},
+                              {"a":"2","b":"0"},
+                              {"a":null,"b":null}] |]
+        { matchStatus = 200
+        , matchHeaders = ["Content-Range" <:> "0-2/*"]
+        }
+
+    it "by a column desc with nulls first" $
+      get "/no_pk?order=a.desc.nullsfirst"
+        `shouldRespondWith` [json| [{"a":null,"b":null},
+                              {"a":"2","b":"0"},
+                              {"a":"1","b":"0"}] |]
+        { matchStatus = 200
+        , matchHeaders = ["Content-Range" <:> "0-2/*"]
+        }
+
+    it "by a column desc with nulls last" $
+      get "/no_pk?order=a.desc.nullslast"
+        `shouldRespondWith` [json| [{"a":"2","b":"0"},
+                              {"a":"1","b":"0"},
+                              {"a":null,"b":null}] |]
+        { matchStatus = 200
+        , matchHeaders = ["Content-Range" <:> "0-2/*"]
+        }
+
+    it "by two columns with nulls and direction specified" $
+      get "/projects?select=client_id,id,name&order=client_id.desc.nullslast,id.desc"
+        `shouldRespondWith` [json|
+          [{"client_id":2,"id":4,"name":"OSX"},
+           {"client_id":2,"id":3,"name":"IOS"},
+           {"client_id":1,"id":2,"name":"Windows 10"},
+           {"client_id":1,"id":1,"name":"Windows 7"},
+           {"client_id":null,"id":5,"name":"Orphan"}]
+        |]
+        { matchStatus  = 200
+        , matchHeaders = ["Content-Range" <:> "0-4/*"]
+        }
+
+    it "by a column with no direction or nulls specified" $
+      get "/items?id=lte.2&order=id"
+        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
+        { matchStatus  = 200
+        , matchHeaders = ["Content-Range" <:> "0-1/*"]
+        }
+
+    it "without other constraints" $
+      get "/items?order=id.asc" `shouldRespondWith` 200
+
+    it "ordering embeded entities" $
+      get "/projects?id=eq.1&select=id, name, tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`
+        [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "ordering embeded entities with alias" $
+      get "/projects?id=eq.1&select=id, name, the_tasks:tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`
+        [json|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "ordering embeded entities, two levels" $
+      get "/projects?id=eq.1&select=id, name, tasks(id, name, users(id, name))&tasks.order=name.asc&tasks.users.order=name.desc" `shouldRespondWith`
+        [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7","users":[{"id":1,"name":"Angela Martin"}]},{"id":1,"name":"Design w7","users":[{"id":3,"name":"Dwight Schrute"},{"id":1,"name":"Angela Martin"}]}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "ordering embeded parents does not break things" $
+      get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`
+        [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]
+
+    context "order syntax errors" $ do
+      it "gives meaningful error messages when asc/desc/nulls{first,last} are misspelled" $ do
+        get "/items?order=id.ac" `shouldRespondWith`
+          [json|{"details":"unexpected \"c\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.ac)\" (line 1, column 4)"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson]
+          }
+        get "/items?order=id.descc" `shouldRespondWith`
+          [json|{"details":"unexpected 'c' expecting delimiter (.), \",\" or end of input","message":"\"failed to parse order (id.descc)\" (line 1, column 8)"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson]
+          }
+        get "/items?order=id.nulsfist" `shouldRespondWith`
+          [json|{"details":"unexpected \"n\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.nulsfist)\" (line 1, column 4)"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson]
+          }
+        get "/items?order=id.nullslasttt" `shouldRespondWith`
+          [json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.nullslasttt)\" (line 1, column 13)"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson]
+          }
+        get "/items?order=id.smth34" `shouldRespondWith`
+          [json|{"details":"unexpected \"s\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.smth34)\" (line 1, column 4)"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+      it "gives meaningful error messages when nulls{first,last} are misspelled after asc/desc" $ do
+        get "/items?order=id.asc.nlsfst" `shouldRespondWith`
+          [json|{"details":"unexpected \"l\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.nlsfst)\" (line 1, column 8)"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson]
+          }
+        get "/items?order=id.asc.nullslasttt" `shouldRespondWith`
+          [json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.asc.nullslasttt)\" (line 1, column 17)"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson]
+          }
+        get "/items?order=id.asc.smth34" `shouldRespondWith`
+          [json|{"details":"unexpected \"s\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.smth34)\" (line 1, column 8)"}|]
+          { matchStatus  = 400
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+  describe "Accept headers" $ do
+    it "should respond an unknown accept type with 415" $
+      request methodGet "/simple_pk"
+              (acceptHdrs "text/unknowntype") ""
+        `shouldRespondWith`
+        [json|{"message":"None of these Content-Types are available: text/unknowntype"}|]
+        { matchStatus  = 415
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+    it "should respond correctly to */* in accept header" $
+      request methodGet "/simple_pk"
+              (acceptHdrs "*/*") ""
+        `shouldRespondWith` 200
+
+    it "*/* should rescue an unknown type" $
+      request methodGet "/simple_pk"
+              (acceptHdrs "text/unknowntype, */*") ""
+        `shouldRespondWith` 200
+
+    it "specific available preference should override */*" $ do
+      r <- request methodGet "/simple_pk"
+              (acceptHdrs "text/csv, */*") ""
+      liftIO $ do
+        let respHeaders = simpleHeaders r
+        respHeaders `shouldSatisfy` matchHeader
+          "Content-Type" "text/csv; charset=utf-8"
+
+    it "honors client preference even when opposite of server preference" $ do
+      r <- request methodGet "/simple_pk"
+              (acceptHdrs "text/csv, application/json") ""
+      liftIO $ do
+        let respHeaders = simpleHeaders r
+        respHeaders `shouldSatisfy` matchHeader
+          "Content-Type" "text/csv; charset=utf-8"
+
+    it "should respond correctly to multiple types in accept header" $
+      request methodGet "/simple_pk"
+              (acceptHdrs "text/unknowntype, text/csv") ""
+        `shouldRespondWith` 200
+
+    it "should respond with CSV to 'text/csv' request" $
+      request methodGet "/simple_pk"
+              (acceptHdrs "text/csv; version=1") ""
+        `shouldRespondWith` "k,extra\nxyyx,u\nxYYx,v"
+        { matchStatus  = 200
+        , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
+        }
+
+  describe "Canonical location" $ do
+    it "Sets Content-Location with alphabetized params" $
+      get "/no_pk?b=eq.1&a=eq.1"
+        `shouldRespondWith` "[]"
+        { matchStatus  = 200
+        , matchHeaders = ["Content-Location" <:> "/no_pk?a=eq.1&b=eq.1"]
+        }
+
+    it "Omits question mark when there are no params" $ do
+      r <- get "/simple_pk"
+      liftIO $ do
+        let respHeaders = simpleHeaders r
+        respHeaders `shouldSatisfy` matchHeader
+          "Content-Location" "/simple_pk"
+
+  describe "weird requests" $ do
+    it "can query as normal" $ do
+      get "/Escap3e;" `shouldRespondWith`
+        [json| [{"so6meIdColumn":1},{"so6meIdColumn":2},{"so6meIdColumn":3},{"so6meIdColumn":4},{"so6meIdColumn":5}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/ghostBusters" `shouldRespondWith`
+        [json| [{"escapeId":1},{"escapeId":3},{"escapeId":5}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "fails if an operator is not given" $
+      get "/ghostBusters?id=0" `shouldRespondWith` [json| {"details":"unexpected \"0\" expecting \"not\" or operator (eq, gt, ...)","message":"\"failed to parse filter (0)\" (line 1, column 1)"} |]
+        { matchStatus  = 400
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+    it "will embed a collection" $
+      get "/Escap3e;?select=ghostBusters(*)" `shouldRespondWith`
+        [json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "will select and filter a column that has spaces" $
+      get "/Server%20Today?select=Just%20A%20Server%20Model&Just%20A%20Server%20Model=like.*91*" `shouldRespondWith`
+        [json|[
+          {"Just A Server Model":" IBM,9113-550 (P5-550)"},
+          {"Just A Server Model":" IBM,9113-550 (P5-550)"},
+          {"Just A Server Model":" IBM,9131-52A (P5-52A)"},
+          {"Just A Server Model":" IBM,9133-55A (P5-55A)"}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "will select and filter a quoted column that has PostgREST reserved characters" $
+      get "/pgrst_reserved_chars?select=%22:arr-%3Eow::cast%22,%22(inside,parens)%22,%22a.dotted.column%22,%22%20%20col%20%20w%20%20space%20%20%22&%22*id*%22=eq.1" `shouldRespondWith`
+        [json|[{":arr->ow::cast":" arrow-1 ","(inside,parens)":" parens-1 ","a.dotted.column":" dotted-1 ","  col  w  space  ":" space-1"}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+  context "binary output" $ do
+    it "can query if a single column is selected" $
+      request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
+        `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
+        { matchStatus = 200
+        , matchHeaders = ["Content-Type" <:> "application/octet-stream"]
+        }
+
+    it "can get raw output with Accept: text/plain" $
+      request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") ""
+        `shouldRespondWith` "Windows 7"
+        { matchStatus = 200
+        , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
+        }
+
+    it "fails if a single column is not selected" $ do
+      request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
+        `shouldRespondWith`
+          [json| {"message":"application/octet-stream requested but more than one column was selected"} |]
+          { matchStatus = 406 }
+
+      request methodGet "/images?select=*&name=eq.A.png"
+          (acceptHdrs "application/octet-stream")
+          ""
+        `shouldRespondWith`
+          [json| {"message":"application/octet-stream requested but more than one column was selected"} |]
+          { matchStatus = 406 }
+
+      request methodGet "/images?name=eq.A.png"
+          (acceptHdrs "application/octet-stream")
+          ""
+        `shouldRespondWith`
+          [json| {"message":"application/octet-stream requested but more than one column was selected"} |]
+          { matchStatus = 406 }
+
+    it "concatenates results if more than one row is returned" $
+      request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (acceptHdrs "application/octet-stream") ""
+        `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
+        { matchStatus = 200
+        , matchHeaders = ["Content-Type" <:> "application/octet-stream"]
+        }
+
+  describe "values with quotes in IN and NOT IN" $ do
+    it "succeeds when only quoted values are present" $ do
+      get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`
+        [json| [{"name":"Hebdon, John"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")" `shouldRespondWith`
+        [json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")&limit=3" `shouldRespondWith`
+        [json| [ { "name": "David White" }, { "name": "Larry Thompson" }, { "name": "Double O Seven(007)" }] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "succeeds w/ and w/o quoted values" $ do
+      get "/w_or_wo_comma_names?name=in.(David White,\"Hebdon, John\")" `shouldRespondWith`
+        [json| [{"name":"Hebdon, John"},{"name":"David White"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\")&limit=3" `shouldRespondWith`
+        [json| [ { "name": "Williams, Mary" }, { "name": "David White" }, { "name": "Double O Seven(007)" }] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/w_or_wo_comma_names?name=in.(\"Double O Seven(007)\")" `shouldRespondWith`
+        [json| [{"name":"Double O Seven(007)"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    context "escaped chars" $ do
+      it "accepts escaped double quotes" $
+        get "/w_or_wo_comma_names?name=in.(\"Double\\\"Quote\\\"McGraw\\\"\")" `shouldRespondWith`
+          [json| [ { "name": "Double\"Quote\"McGraw\"" } ] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "accepts escaped backslashes" $ do
+        get "/w_or_wo_comma_names?name=in.(\"\\\\\")" `shouldRespondWith`
+          [json| [{ "name": "\\" }] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/w_or_wo_comma_names?name=in.(\"/\\\\Slash/\\\\Beast/\\\\\")" `shouldRespondWith`
+          [json| [ { "name": "/\\Slash/\\Beast/\\" } ] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "passes any escaped char as the same char" $
+        get "/w_or_wo_comma_names?name=in.(\"D\\a\\vid W\\h\\ite\")" `shouldRespondWith`
+          [json| [{ "name": "David White" }] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+  describe "IN values without quotes" $ do
+    it "accepts single double quotes as values" $ do
+      get "/w_or_wo_comma_names?name=in.(\")" `shouldRespondWith`
+        [json| [{ "name": "\"" }] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/w_or_wo_comma_names?name=in.(Double\"Quote\"McGraw\")" `shouldRespondWith`
+        [json| [ { "name": "Double\"Quote\"McGraw\"" } ] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+    it "accepts backslashes as values" $ do
+      get "/w_or_wo_comma_names?name=in.(\\)" `shouldRespondWith`
+        [json| [{ "name": "\\" }] |]
+        { matchHeaders = [matchContentTypeJson] }
+      get "/w_or_wo_comma_names?name=in.(/\\Slash/\\Beast/\\)" `shouldRespondWith`
+        [json| [ { "name": "/\\Slash/\\Beast/\\" } ] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+  describe "IN and NOT IN empty set" $ do
+    context "returns an empty result for IN when no value is present" $ do
+      it "works for integer" $
+        get "/items_with_different_col_types?int_data=in.()" `shouldRespondWith`
+          [json| [] |] { matchHeaders = [matchContentTypeJson] }
+      it "works for text" $
+        get "/items_with_different_col_types?text_data=in.()" `shouldRespondWith`
+          [json| [] |] { matchHeaders = [matchContentTypeJson] }
+      it "works for bool" $
+        get "/items_with_different_col_types?bool_data=in.()" `shouldRespondWith`
+          [json| [] |] { matchHeaders = [matchContentTypeJson] }
+      it "works for bytea" $
+        get "/items_with_different_col_types?bin_data=in.()" `shouldRespondWith`
+          [json| [] |] { matchHeaders = [matchContentTypeJson] }
+      it "works for char" $
+        get "/items_with_different_col_types?char_data=in.()" `shouldRespondWith`
+          [json| [] |] { matchHeaders = [matchContentTypeJson] }
+      it "works for date" $
+        get "/items_with_different_col_types?date_data=in.()" `shouldRespondWith`
+          [json| [] |] { matchHeaders = [matchContentTypeJson] }
+      it "works for real" $
+        get "/items_with_different_col_types?real_data=in.()" `shouldRespondWith`
+          [json| [] |] { matchHeaders = [matchContentTypeJson] }
+      it "works for time" $
+        get "/items_with_different_col_types?time_data=in.()" `shouldRespondWith`
+          [json| [] |] { matchHeaders = [matchContentTypeJson] }
+
+    it "returns all results for not.in when no value is present" $
+      get "/items_with_different_col_types?int_data=not.in.()&select=int_data" `shouldRespondWith`
+        [json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }
+
+    it "returns an empty result ignoring spaces" $
+      get "/items_with_different_col_types?int_data=in.(    )" `shouldRespondWith`
+        [json| [] |] { matchHeaders = [matchContentTypeJson] }
+
+    it "only returns an empty result set if the in value is empty" $
+      get "/items_with_different_col_types?int_data=in.( ,3,4)"
+        `shouldRespondWith` (
+        if actualPgVersion >= pgVersion121 then
+        [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"\""} |]
+        else
+        [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
+                            )
+        { matchStatus = 400
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+  describe "Embedding when column name = table name" $ do
+    it "works with child embeds" $
+      get "/being?select=*,descendant(*)&limit=1" `shouldRespondWith`
+        [json|[{"being":1,"descendant":[{"descendant":1,"being":1},{"descendant":2,"being":1},{"descendant":3,"being":1}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+    it "works with many to many embeds" $
+      get "/being?select=*,part(*)&limit=1" `shouldRespondWith`
+        [json|[{"being":1,"part":[{"part":1}]}]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+  describe "Foreign table" $ do
+    it "can be queried by using regular filters" $
+      get "/projects_dump?id=in.(1,2,3)" `shouldRespondWith`
+        [json| [{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}, {"id":3,"name":"IOS","client_id":2}]|]
+        { matchHeaders = [matchContentTypeJson] }
+    it "can be queried with select, order and limit" $
+      get "/projects_dump?select=id,name&order=id.desc&limit=3" `shouldRespondWith`
+        [json| [{"id":5,"name":"Orphan"}, {"id":4,"name":"OSX"}, {"id":3,"name":"IOS"}] |]
+        { matchHeaders = [matchContentTypeJson] }
+
+  it "cannot use ltree(in public schema) extension operators if no extra search path added" $
+    get "/ltree_sample?path=cd.Top.Science.Astronomy" `shouldRespondWith` 400
+
+  context "VIEW that has a source FK based on a UNIQUE key" $
+    it "can be embedded" $
+      get "/referrals?select=site,link:pages(url)" `shouldRespondWith`
+        [json| [
+         {"site":"github.com",     "link":{"url":"http://postgrest.org/en/v6.0/api.html"}},
+         {"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}
+        ]|]
+        { matchHeaders = [matchContentTypeJson] }
+
+  it "shouldn't produce a Content-Profile header since only a single schema is exposed" $ do
+    r <- get "/items"
+    liftIO $ do
+      let respHeaders = simpleHeaders r
+      respHeaders `shouldSatisfy` noProfileHeader
diff --git a/test/spec/Feature/RangeSpec.hs b/test/spec/Feature/RangeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/RangeSpec.hs
@@ -0,0 +1,352 @@
+module Feature.RangeSpec where
+
+import qualified Data.ByteString.Lazy as BL
+
+import Network.Wai      (Application)
+import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude  hiding (get)
+import SpecHelper
+
+defaultRange :: BL.ByteString
+defaultRange = [json| { "min": 0, "max": 15 } |]
+
+emptyRange :: BL.ByteString
+emptyRange = [json| { "min": 2, "max": 2 } |]
+
+spec :: SpecWith ((), Application)
+spec = do
+  describe "POST /rpc/getitemrange" $ do
+    context "without range headers" $ do
+      context "with response under server size limit" $
+        it "returns whole range with status 200" $
+           post "/rpc/getitemrange" defaultRange `shouldRespondWith` 200
+
+      context "when I don't want the count" $ do
+        it "returns range Content-Range with */* for empty range" $
+          request methodPost "/rpc/getitemrange" [] emptyRange
+            `shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}
+
+        it "returns range Content-Range with range/*" $
+          post "/rpc/getitemrange?order=id"
+              defaultRange
+            `shouldRespondWith`
+              [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
+              { matchHeaders = ["Content-Range" <:> "0-14/*"] }
+
+    context "with range headers" $ do
+      context "of acceptable range" $ do
+        it "succeeds with partial content" $ do
+          r <- request methodPost  "/rpc/getitemrange"
+                       (rangeHdrs $ ByteRangeFromTo 0 1) defaultRange
+          liftIO $ do
+            simpleHeaders r `shouldSatisfy`
+              matchHeader "Content-Range" "0-1/*"
+            simpleStatus r `shouldBe` ok200
+
+        it "understands open-ended ranges" $
+          request methodPost "/rpc/getitemrange"
+                  (rangeHdrs $ ByteRangeFrom 0) defaultRange
+            `shouldRespondWith` 200
+
+        it "returns an empty body when there are no results" $
+          request methodPost "/rpc/getitemrange"
+                  (rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
+            `shouldRespondWith` "[]"
+            { matchStatus  = 200
+            , matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+        it "allows one-item requests" $ do
+          r <- request methodPost  "/rpc/getitemrange"
+                       (rangeHdrs $ ByteRangeFromTo 0 0) defaultRange
+          liftIO $ do
+            simpleHeaders r `shouldSatisfy`
+              matchHeader "Content-Range" "0-0/*"
+            simpleStatus r `shouldBe` ok200
+
+        it "handles ranges beyond collection length via truncation" $ do
+          r <- request methodPost  "/rpc/getitemrange"
+                       (rangeHdrs $ ByteRangeFromTo 10 100) defaultRange
+          liftIO $ do
+            simpleHeaders r `shouldSatisfy`
+              matchHeader "Content-Range" "10-14/*"
+            simpleStatus r `shouldBe` ok200
+
+      context "of invalid range" $ do
+        it "fails with 416 for offside range" $
+          request methodPost  "/rpc/getitemrange"
+                  (rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
+            `shouldRespondWith` 416
+
+        it "refuses a range with nonzero start when there are no items" $
+          request methodPost "/rpc/getitemrange"
+                  (rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
+            `shouldRespondWith` "[]"
+            { matchStatus  = 416
+            , matchHeaders = ["Content-Range" <:> "*/0"]
+            }
+
+        it "refuses a range requesting start past last item" $
+          request methodPost "/rpc/getitemrange"
+                  (rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
+            `shouldRespondWith` "[]"
+            { matchStatus  = 416
+            , matchHeaders = ["Content-Range" <:> "*/15"]
+            }
+
+  describe "GET /items" $ do
+    context "without range headers" $ do
+      context "with response under server size limit" $
+        it "returns whole range with status 200" $
+          get "/items" `shouldRespondWith` 200
+
+      context "when I don't want the count" $ do
+        it "returns range Content-Range with /*" $
+          request methodGet "/menagerie"
+                  [("Prefer", "count=none")] ""
+            `shouldRespondWith` "[]"
+            { matchStatus  = 200
+            , matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+        it "returns range Content-Range with range/*" $
+          request methodGet "/items?order=id"
+                  [("Prefer", "count=none")] ""
+            `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
+            { matchHeaders = ["Content-Range" <:> "0-14/*"] }
+
+        it "returns range Content-Range with range/* even using other filters" $
+          request methodGet "/items?id=eq.1&order=id"
+                  [("Prefer", "count=none")] ""
+            `shouldRespondWith` [json| [{"id":1}] |]
+            { matchHeaders = ["Content-Range" <:> "0-0/*"] }
+
+    context "with limit/offset parameters" $ do
+      it "no parameters return everything" $
+        get "/items?select=id&order=id.asc"
+          `shouldRespondWith`
+          [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "0-14/*"]
+          }
+      it "top level limit with parameter" $
+        get "/items?select=id&order=id.asc&limit=3"
+          `shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|]
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "0-2/*"]
+          }
+      it "headers override get parameters" $
+        request methodGet  "/items?select=id&order=id.asc&limit=3"
+                     (rangeHdrs $ ByteRangeFromTo 0 1) ""
+          `shouldRespondWith` [json|[{"id":1},{"id":2}]|]
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "0-1/*"]
+          }
+
+      it "limit works on all levels" $
+        get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
+          `shouldRespondWith`
+          [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "0-0/*"]
+          }
+
+      it "limit and offset works on first level" $ do
+        get "/items?select=id&order=id.asc&limit=3&offset=2"
+          `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "2-4/*"]
+          }
+        request methodHead "/items?select=id&order=id.asc&limit=3&offset=2" [] mempty
+          `shouldRespondWith` ""
+          { matchStatus  = 200
+          , matchHeaders = ["Content-Range" <:> "2-4/*"]
+          }
+
+      it "succeeds if offset equals 0 as a no-op" $
+        get "/items?select=id&offset=0&order=id"
+          `shouldRespondWith`
+            [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
+            { matchHeaders = ["Content-Range" <:> "0-14/*"] }
+
+      it "succeeds if offset is negative as a no-op" $
+        get "/items?select=id&offset=-4&order=id"
+          `shouldRespondWith`
+            [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
+            { matchHeaders = ["Content-Range" <:> "0-14/*"] }
+
+      it "fails if limit equals 0" $
+        get "/items?select=id&limit=0"
+          `shouldRespondWith` [json|{"message":"HTTP Range error"}|]
+          { matchStatus  = 416
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+      it "fails if limit is negative" $
+        get "/items?select=id&limit=-1"
+          `shouldRespondWith` [json|{"message":"HTTP Range error"}|]
+          { matchStatus  = 416
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+    context "when count=planned" $ do
+      it "obtains a filtered range" $ do
+        request methodGet "/items?select=id&id=gt.8"
+            [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            [json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|]
+            { matchStatus  = 206
+            , matchHeaders = ["Content-Range" <:> "0-6/8"]
+            }
+
+        request methodGet "/child_entities?select=id&id=gt.3"
+            [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            [json|[{"id":4}, {"id":5}, {"id":6}]|]
+            { matchStatus  = 206
+            , matchHeaders = ["Content-Range" <:> "0-2/4"]
+            }
+
+        request methodGet "/getallprojects_view?select=id&id=lt.3"
+            [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            [json|[{"id":1}, {"id":2}]|]
+            { matchStatus  = 206
+            , matchHeaders = ["Content-Range" <:> "0-1/673"]
+            }
+
+      it "obtains the full range" $ do
+        request methodHead "/items"
+            [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 200
+            , matchHeaders = ["Content-Range" <:> "0-14/15"]
+            }
+
+        request methodHead "/child_entities"
+            [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 200
+            , matchHeaders = ["Content-Range" <:> "0-5/6"]
+            }
+
+        request methodHead "/getallprojects_view"
+            [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 206
+            , matchHeaders = ["Content-Range" <:> "0-4/2019"]
+            }
+
+      it "ignores limit/offset on the planned count" $ do
+        request methodHead "/items?limit=2&offset=3"
+            [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 206
+            , matchHeaders = ["Content-Range" <:> "3-4/15"]
+            }
+
+        request methodHead "/child_entities?limit=2"
+           [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 206
+            , matchHeaders = ["Content-Range" <:> "0-1/6"]
+            }
+
+        request methodHead "/getallprojects_view?limit=2"
+            [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 206
+            , matchHeaders = ["Content-Range" <:> "0-1/2019"]
+            }
+
+      it "works with two levels" $
+        request methodHead "/child_entities?select=*,entities(*)"
+            [("Prefer", "count=planned")]
+            ""
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 200
+            , matchHeaders = ["Content-Range" <:> "0-5/6"]
+            }
+
+    context "with range headers" $ do
+      context "of acceptable range" $ do
+        it "succeeds with partial content" $ do
+          r <- request methodGet  "/items"
+                       (rangeHdrs $ ByteRangeFromTo 0 1) ""
+          liftIO $ do
+            simpleHeaders r `shouldSatisfy`
+              matchHeader "Content-Range" "0-1/*"
+            simpleStatus r `shouldBe` ok200
+
+        it "understands open-ended ranges" $
+          request methodGet "/items"
+                  (rangeHdrs $ ByteRangeFrom 0) ""
+            `shouldRespondWith` 200
+
+        it "returns an empty body when there are no results" $
+          request methodGet "/menagerie"
+                  (rangeHdrs $ ByteRangeFromTo 0 1) ""
+            `shouldRespondWith` "[]"
+            { matchStatus  = 200
+            , matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+        it "allows one-item requests" $ do
+          r <- request methodGet  "/items"
+                       (rangeHdrs $ ByteRangeFromTo 0 0) ""
+          liftIO $ do
+            simpleHeaders r `shouldSatisfy`
+              matchHeader "Content-Range" "0-0/*"
+            simpleStatus r `shouldBe` ok200
+
+        it "handles ranges beyond collection length via truncation" $ do
+          r <- request methodGet  "/items"
+                       (rangeHdrs $ ByteRangeFromTo 10 100) ""
+          liftIO $ do
+            simpleHeaders r `shouldSatisfy`
+              matchHeader "Content-Range" "10-14/*"
+            simpleStatus r `shouldBe` ok200
+
+      context "of invalid range" $ do
+        it "fails with 416 for offside range" $
+          request methodGet  "/items"
+                  (rangeHdrs $ ByteRangeFromTo 1 0) ""
+            `shouldRespondWith` 416
+
+        it "refuses a range with nonzero start when there are no items" $
+          request methodGet "/menagerie"
+                  (rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
+            `shouldRespondWith` "[]"
+            { matchStatus  = 416
+            , matchHeaders = ["Content-Range" <:> "*/0"]
+            }
+
+        it "refuses a range requesting start past last item" $
+          request methodGet "/items"
+                  (rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
+            `shouldRespondWith` "[]"
+            { matchStatus  = 416
+            , matchHeaders = ["Content-Range" <:> "*/15"]
+            }
diff --git a/test/spec/Feature/RawOutputTypesSpec.hs b/test/spec/Feature/RawOutputTypesSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/RawOutputTypesSpec.hs
@@ -0,0 +1,33 @@
+module Feature.RawOutputTypesSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude
+import SpecHelper (acceptHdrs)
+
+spec :: SpecWith ((), Application)
+spec = describe "When raw-media-types config variable is missing or left empty" $ do
+  let firefoxAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
+      chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
+  it "responds json to a GET request with Firefox Accept headers" $
+    request methodGet "/items?id=eq.1" firefoxAcceptHdrs ""
+      `shouldRespondWith` [json| [{"id":1}] |]
+        { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
+  it "responds json to a GET request with Chrome Accept headers" $
+    request methodGet "/items?id=eq.1" chromeAcceptHdrs ""
+      `shouldRespondWith` [json| [{"id":1}] |]
+        { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
+
+  it "responds json to a GET request to RPC with Firefox Accept headers" $
+    request methodGet "/rpc/get_projects_below?id=3" firefoxAcceptHdrs ""
+      `shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
+        { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
+  it "responds json to a GET request to RPC with Chrome Accept headers" $
+    request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
+      `shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
+        { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
diff --git a/test/spec/Feature/RollbackSpec.hs b/test/spec/Feature/RollbackSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/RollbackSpec.hs
@@ -0,0 +1,260 @@
+module Feature.RollbackSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude hiding (get)
+
+-- two helpers functions to make sure that each test can setup and cleanup properly
+
+-- creates Item to work with for PATCH and DELETE
+postItem =
+  request methodPost "/items"
+      [("Prefer", "tx=commit"), ("Prefer", "resolution=ignore-duplicates")]
+      [json|{"id":0}|]
+    `shouldRespondWith`
+      ""
+      { matchStatus  = 201 }
+
+-- removes Items left over from POST, PUT, and PATCH
+deleteItems =
+  request methodDelete "/items?id=lte.0"
+      [("Prefer", "tx=commit")]
+      ""
+    `shouldRespondWith`
+      ""
+      { matchStatus  = 204 }
+
+preferDefault  = [("Prefer", "return=representation")]
+preferCommit   = [("Prefer", "return=representation"), ("Prefer", "tx=commit")]
+preferRollback = [("Prefer", "return=representation"), ("Prefer", "tx=rollback")]
+
+withoutPreferenceApplied      = []
+withPreferenceCommitApplied   = [ "Preference-Applied" <:> "tx=commit" ]
+withPreferenceRollbackApplied = [ "Preference-Applied" <:> "tx=rollback" ]
+
+shouldRespondToReads reqHeaders respHeaders = do
+  it "responds to GET" $ do
+    request methodGet "/items?id=eq.1"
+        reqHeaders
+        ""
+      `shouldRespondWith`
+        [json|[{"id":1}]|]
+        { matchHeaders = respHeaders }
+
+  it "responds to HEAD" $ do
+    request methodHead "/items?id=eq.1"
+        reqHeaders
+        ""
+      `shouldRespondWith`
+        ""
+        { matchHeaders = respHeaders }
+
+  it "responds to GET on RPC" $ do
+    request methodGet "/rpc/search?id=1"
+        reqHeaders
+        ""
+      `shouldRespondWith`
+        [json|[{"id":1}]|]
+        { matchHeaders = respHeaders }
+
+  it "responds to POST on RPC" $ do
+    request methodPost "/rpc/search"
+        reqHeaders
+        [json|{"id":1}|]
+      `shouldRespondWith`
+        [json|[{"id":1}]|]
+        { matchHeaders = respHeaders }
+
+shouldRaiseExceptions reqHeaders respHeaders = do
+  it "raises immediate constraints" $ do
+    request methodPost "/rpc/raise_constraint"
+        reqHeaders
+        ""
+      `shouldRespondWith`
+        [json|{
+          "hint":null,
+          "details":"Key (col)=(1) already exists.",
+          "code":"23505",
+          "message":"duplicate key value violates unique constraint \"deferrable_unique_constraint_col_key\""
+        }|]
+        { matchStatus = 409
+        , matchHeaders = respHeaders }
+
+  it "raises deferred constraints" $ do
+    request methodPost "/rpc/raise_constraint"
+        reqHeaders
+        [json|{"deferred": true}|]
+      `shouldRespondWith`
+        [json|{
+          "hint":null,
+          "details":"Key (col)=(1) already exists.",
+          "code":"23505",
+          "message":"duplicate key value violates unique constraint \"deferrable_unique_constraint_col_key\""
+        }|]
+        { matchStatus = 409
+        , matchHeaders = respHeaders }
+
+shouldPersistMutations reqHeaders respHeaders = do
+  it "does persist post" $ do
+    request methodPost "/items"
+        reqHeaders
+        [json|{"id":0}|]
+      `shouldRespondWith`
+        [json|[{"id":0}]|]
+        { matchStatus  = 201
+        , matchHeaders = respHeaders }
+    get "/items?id=eq.0"
+      `shouldRespondWith`
+        [json|[{"id":0}]|]
+    deleteItems
+
+  it "does persist put" $ do
+    request methodPut "/items?id=eq.0"
+        reqHeaders
+        [json|{"id":0}|]
+      `shouldRespondWith`
+        [json|[{"id":0}]|]
+        { matchHeaders = respHeaders }
+    get "/items?id=eq.0"
+      `shouldRespondWith`
+        [json|[{"id":0}]|]
+    deleteItems
+
+  it "does persist patch" $ do
+    postItem
+    request methodPatch "/items?id=eq.0"
+        reqHeaders
+        [json|{"id":-1}|]
+      `shouldRespondWith`
+        [json|[{"id":-1}]|]
+        { matchHeaders = respHeaders }
+    get "/items?id=eq.0"
+      `shouldRespondWith`
+        [json|[]|]
+    get "/items?id=eq.-1"
+      `shouldRespondWith`
+        [json|[{"id":-1}]|]
+    deleteItems
+
+  it "does persist delete" $ do
+    postItem
+    request methodDelete "/items?id=eq.0"
+        reqHeaders
+        ""
+      `shouldRespondWith`
+        [json|[{"id":0}]|]
+        { matchHeaders = respHeaders }
+    get "/items?id=eq.0"
+      `shouldRespondWith`
+        [json|[]|]
+
+shouldNotPersistMutations reqHeaders respHeaders = do
+  it "does not persist post" $ do
+    request methodPost "/items"
+        reqHeaders
+        [json|{"id":0}|]
+      `shouldRespondWith`
+        [json|[{"id":0}]|]
+        { matchStatus  = 201
+        , matchHeaders = respHeaders }
+    get "/items?id=eq.0"
+      `shouldRespondWith`
+        [json|[]|]
+
+  it "does not persist put" $ do
+    request methodPut "/items?id=eq.0"
+        reqHeaders
+        [json|{"id":0}|]
+      `shouldRespondWith`
+        [json|[{"id":0}]|]
+        { matchHeaders = respHeaders }
+    get "/items?id=eq.0"
+      `shouldRespondWith`
+        [json|[]|]
+
+  it "does not persist patch" $ do
+    request methodPatch "/items?id=eq.1"
+        reqHeaders
+        [json|{"id":0}|]
+      `shouldRespondWith`
+        [json|[{"id":0}]|]
+        { matchHeaders = respHeaders }
+    get "/items?id=eq.0"
+      `shouldRespondWith`
+        [json|[]|]
+    get "items?id=eq.1"
+      `shouldRespondWith`
+        [json|[{"id":1}]|]
+
+  it "does not persist delete" $ do
+    request methodDelete "/items?id=eq.1"
+        reqHeaders
+        ""
+      `shouldRespondWith`
+        [json|[{"id":1}]|]
+        { matchHeaders = respHeaders }
+    get "/items?id=eq.1"
+      `shouldRespondWith`
+        [json|[{"id":1}]|]
+
+allowed :: SpecWith ((), Application)
+allowed = describe "tx-allow-override = true" $ do
+  describe "without Prefer tx" $ do
+    preferDefault `shouldRespondToReads` withoutPreferenceApplied
+    preferDefault `shouldNotPersistMutations` withoutPreferenceApplied
+    preferDefault `shouldRaiseExceptions` withoutPreferenceApplied
+
+  describe "Prefer tx=commit" $ do
+    preferCommit `shouldRespondToReads` withPreferenceCommitApplied
+    preferCommit `shouldPersistMutations` withPreferenceCommitApplied
+    -- Exceptions are always without preference applied,
+    -- because they return before the end of the transaction.
+    preferCommit `shouldRaiseExceptions` withoutPreferenceApplied
+
+  describe "Prefer tx=rollback" $ do
+    preferRollback `shouldRespondToReads` withPreferenceRollbackApplied
+    preferRollback `shouldNotPersistMutations` withPreferenceRollbackApplied
+    -- Exceptions are always without preference applied,
+    -- because they return before the end of the transaction.
+    preferRollback `shouldRaiseExceptions` withoutPreferenceApplied
+
+disallowed :: SpecWith ((), Application)
+disallowed = describe "tx-rollback-all = false, tx-allow-override = false" $ do
+  describe "without Prefer tx" $ do
+    preferDefault `shouldRespondToReads` withoutPreferenceApplied
+    preferDefault `shouldPersistMutations` withoutPreferenceApplied
+    preferDefault `shouldRaiseExceptions` withoutPreferenceApplied
+
+  describe "Prefer tx=commit" $ do
+    preferCommit `shouldRespondToReads` withoutPreferenceApplied
+    preferCommit `shouldPersistMutations` withoutPreferenceApplied
+    preferCommit `shouldRaiseExceptions` withoutPreferenceApplied
+
+  describe "Prefer tx=rollback" $ do
+    preferRollback `shouldRespondToReads` withoutPreferenceApplied
+    preferRollback `shouldPersistMutations` withoutPreferenceApplied
+    preferRollback `shouldRaiseExceptions` withoutPreferenceApplied
+
+
+forced :: SpecWith ((), Application)
+forced = describe "tx-rollback-all = true, tx-allow-override = false" $ do
+  describe "without Prefer tx" $ do
+    preferDefault `shouldRespondToReads` withoutPreferenceApplied
+    preferDefault `shouldNotPersistMutations` withoutPreferenceApplied
+    preferDefault `shouldRaiseExceptions` withoutPreferenceApplied
+
+  describe "Prefer tx=commit" $ do
+    preferCommit `shouldRespondToReads` withoutPreferenceApplied
+    preferCommit `shouldNotPersistMutations` withoutPreferenceApplied
+    preferCommit `shouldRaiseExceptions` withoutPreferenceApplied
+
+  describe "Prefer tx=rollback" $ do
+    preferRollback `shouldRespondToReads` withoutPreferenceApplied
+    preferRollback `shouldNotPersistMutations` withoutPreferenceApplied
+    preferRollback `shouldRaiseExceptions` withoutPreferenceApplied
+
diff --git a/test/spec/Feature/RootSpec.hs b/test/spec/Feature/RootSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/RootSpec.hs
@@ -0,0 +1,34 @@
+module Feature.RootSpec where
+
+import Network.HTTP.Types
+import Network.Wai        (Application)
+
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude hiding (get)
+
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "root spec function" $ do
+    it "accepts application/openapi+json" $
+      request methodGet "/"
+        [("Accept","application/openapi+json")] "" `shouldRespondWith`
+        [json|{
+           "swagger": "2.0",
+           "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}
+          }|]
+        { matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] }
+
+    it "accepts application/json" $
+      request methodGet "/"
+        [("Accept", "application/json")] "" `shouldRespondWith`
+        [json| {
+            "tableName": "orders_view", "tableSchema": "test",
+            "tableDeletable": true, "tableUpdatable": true,
+            "tableInsertable": true, "tableDescription": null
+          } |]
+        { matchHeaders = [matchContentTypeJson] }
diff --git a/test/spec/Feature/RpcPreRequestGucsSpec.hs b/test/spec/Feature/RpcPreRequestGucsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/RpcPreRequestGucsSpec.hs
@@ -0,0 +1,84 @@
+module Feature.RpcPreRequestGucsSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec          hiding (pendingWith)
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude hiding (get, put)
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "GUC headers on all methods via pre-request" $ do
+    it "succeeds setting the headers on POST" $
+      post "/items"
+          [json|[{"id": 11111}]|]
+        `shouldRespondWith` ""
+          { matchStatus = 201
+          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
+          }
+
+    it "succeeds setting the headers on GET and HEAD" $ do
+      request methodGet "/items?id=eq.1"
+          [("User-Agent", "MSIE 6.0")]
+          ""
+        `shouldRespondWith`
+          [json|[{"id": 1}]|]
+          { matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"] }
+
+      request methodHead "/items?id=eq.1"
+          [("User-Agent", "MSIE 7.0")]
+          ""
+        `shouldRespondWith`
+          ""
+          { matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"] }
+
+      request methodHead "/projects"
+          [("Accept", "text/csv")]
+          ""
+        `shouldRespondWith`
+          ""
+          { matchHeaders = ["Content-Disposition" <:> "attachment; filename=projects.csv"] }
+
+    it "succeeds setting the headers on PATCH" $
+        patch "/items?id=eq.1"
+            [json|[{"id": 11111}]|]
+          `shouldRespondWith` ""
+            { matchStatus = 204
+            , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
+            }
+
+    it "succeeds setting the headers on PUT" $
+      put "/items?id=eq.1"
+          [json|[{"id": 1}]|]
+        `shouldRespondWith` ""
+          { matchStatus = 204
+          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
+          }
+
+    it "succeeds setting the headers on DELETE" $
+      delete "/items?id=eq.1"
+        `shouldRespondWith`
+          ""
+          { matchStatus = 204
+          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
+          }
+    it "can override the Content-Type header" $ do
+      request methodHead "/clients?id=eq.1"
+          []
+          ""
+        `shouldRespondWith`
+          ""
+          { matchStatus = 200
+          , matchHeaders = ["Content-Type" <:> "application/custom+json"]
+          }
+      request methodHead "/rpc/getallprojects"
+          []
+          ""
+        `shouldRespondWith`
+          ""
+          { matchStatus = 200
+          , matchHeaders = ["Content-Type" <:> "application/custom+json"]
+          }
diff --git a/test/spec/Feature/RpcSpec.hs b/test/spec/Feature/RpcSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/RpcSpec.hs
@@ -0,0 +1,1266 @@
+module Feature.RpcSpec where
+
+import qualified Data.ByteString.Lazy as BL (empty, readFile)
+
+import Network.Wai      (Application)
+import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
+
+import Network.HTTP.Types
+import System.IO.Unsafe    (unsafePerformIO)
+import Test.Hspec          hiding (pendingWith)
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+import Text.Heredoc
+
+import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
+                                   pgVersion109, pgVersion110,
+                                   pgVersion112, pgVersion114,
+                                   pgVersion140)
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion =
+  describe "remote procedure call" $ do
+    context "a proc that returns a set" $ do
+      it "returns paginated results" $ do
+        request methodPost "/rpc/getitemrange"
+                (rangeHdrs (ByteRangeFromTo 0 0))  [json| { "min": 2, "max": 4 } |]
+           `shouldRespondWith` [json| [{"id":3}] |]
+            { matchStatus = 200
+            , matchHeaders = ["Content-Range" <:> "0-0/*"]
+            }
+        request methodGet "/rpc/getitemrange?min=2&max=4"
+                (rangeHdrs (ByteRangeFromTo 0 0)) ""
+           `shouldRespondWith` [json| [{"id":3}] |]
+            { matchStatus = 200
+            , matchHeaders = ["Content-Range" <:> "0-0/*"]
+            }
+        request methodHead "/rpc/getitemrange?min=2&max=4"
+                (rangeHdrs (ByteRangeFromTo 0 0)) ""
+           `shouldRespondWith` ""
+            { matchStatus = 200
+            , matchHeaders = ["Content-Range" <:> "0-0/*"]
+            }
+
+      it "includes total count if requested" $ do
+        request methodPost "/rpc/getitemrange"
+                (rangeHdrsWithCount (ByteRangeFromTo 0 0))
+                [json| { "min": 2, "max": 4 } |]
+           `shouldRespondWith` [json| [{"id":3}] |]
+            { matchStatus = 206 -- it now knows the response is partial
+            , matchHeaders = ["Content-Range" <:> "0-0/2"]
+            }
+        request methodGet "/rpc/getitemrange?min=2&max=4"
+                (rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""
+           `shouldRespondWith` [json| [{"id":3}] |]
+            { matchStatus = 206
+            , matchHeaders = ["Content-Range" <:> "0-0/2"]
+            }
+        request methodHead "/rpc/getitemrange?min=2&max=4"
+                (rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""
+           `shouldRespondWith` ""
+            { matchStatus = 206
+            , matchHeaders = ["Content-Range" <:> "0-0/2"]
+            }
+
+      it "returns proper json" $ do
+        post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
+          [json| [ {"id": 3}, {"id":4} ] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/rpc/getitemrange?min=2&max=4" `shouldRespondWith`
+          [json| [ {"id": 3}, {"id":4} ] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "returns CSV" $ do
+        request methodPost "/rpc/getitemrange"
+                (acceptHdrs "text/csv")
+                [json| { "min": 2, "max": 4 } |]
+           `shouldRespondWith` "id\n3\n4"
+            { matchStatus = 200
+            , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
+            }
+        request methodGet "/rpc/getitemrange?min=2&max=4"
+                (acceptHdrs "text/csv") ""
+           `shouldRespondWith` "id\n3\n4"
+            { matchStatus = 200
+            , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
+            }
+        request methodHead "/rpc/getitemrange?min=2&max=4"
+                (acceptHdrs "text/csv") ""
+           `shouldRespondWith` ""
+            { matchStatus = 200
+            , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
+            }
+
+    context "unknown function" $ do
+      it "returns 404" $
+        post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404
+
+      it "should fail with 404 on unknown proc name" $
+        get "/rpc/fake" `shouldRespondWith` 404
+
+      it "should fail with 404 on unknown proc args" $ do
+        get "/rpc/sayhello" `shouldRespondWith` 404
+        get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
+
+      it "should not ignore unknown args and fail with 404" $
+        get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`
+        [json| {
+          "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
+          "message":"Could not find the test.add_them(a, b, smthelse) function in the schema cache" } |]
+        { matchStatus  = 404
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+      it "should fail with 404 when no json arg is found with prefer single object" $
+        request methodPost "/rpc/sayhello"
+          [("Prefer","params=single-object")]
+          [json|{}|]
+        `shouldRespondWith`
+          [json| {
+            "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
+            "message":"Could not find the test.sayhello function with a single json or jsonb parameter in the schema cache" } |]
+        { matchStatus  = 404
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+      it "should fail with 404 for overloaded functions with unknown args" $ do
+        get "/rpc/overloaded?wrong_arg=value" `shouldRespondWith`
+          [json| {
+            "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
+            "message":"Could not find the test.overloaded(wrong_arg) function in the schema cache" } |]
+          { matchStatus  = 404
+          , matchHeaders = [matchContentTypeJson]
+          }
+        get "/rpc/overloaded?a=1&b=2&wrong_arg=value" `shouldRespondWith`
+          [json| {
+            "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
+            "message":"Could not find the test.overloaded(a, b, wrong_arg) function in the schema cache" } |]
+          { matchStatus  = 404
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+    context "ambiguous overloaded functions with same parameters' names but different types" $ do
+      it "should fail with 300 Multiple Choices without explicit type casts" $
+        get "/rpc/overloaded_same_args?arg=value" `shouldRespondWith`
+          [json| {
+            "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved",
+            "message":"Could not choose the best candidate function between: test.overloaded_same_args(arg => integer), test.overloaded_same_args(arg => xml), test.overloaded_same_args(arg => text, num => integer)"}|]
+          { matchStatus  = 300
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+    it "works when having uppercase identifiers" $ do
+      get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith`
+        [json|{"user": "mscott", "fullName": "Michael Scott", "SSN": "401-32-XXXX"}|]
+        { matchHeaders = [matchContentTypeJson] }
+      post "/rpc/quotedFunction"
+        [json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
+        `shouldRespondWith`
+        [json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
+        { matchHeaders = [matchContentTypeJson] }
+
+    context "shaping the response returned by a proc" $ do
+      it "returns a project" $ do
+        post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`
+          [json|[{"id":1,"name":"Windows 7","client_id":1}]|]
+        get "/rpc/getproject?id=1" `shouldRespondWith`
+          [json|[{"id":1,"name":"Windows 7","client_id":1}]|]
+
+      it "can filter proc results" $ do
+        post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`
+          [json|[{"id":2},{"id":3},{"id":4}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" `shouldRespondWith`
+          [json|[{"id":2},{"id":3},{"id":4}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "can limit proc results" $ do
+        post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1" [json| {} |]
+          `shouldRespondWith` [json|[{"id":3},{"id":4}]|]
+             { matchStatus = 200
+             , matchHeaders = ["Content-Range" <:> "1-2/*"] }
+        get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1"
+          `shouldRespondWith` [json|[{"id":3},{"id":4}]|]
+             { matchStatus = 200
+             , matchHeaders = ["Content-Range" <:> "1-2/*"] }
+
+      it "select works on the first level" $ do
+        post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`
+          [json|[{"id":1,"name":"Windows 7"}]|]
+        get "/rpc/getproject?id=1&select=id,name" `shouldRespondWith`
+          [json|[{"id":1,"name":"Windows 7"}]|]
+
+    context "foreign entities embedding" $ do
+      it "can embed if related tables are in the exposed schema" $ do
+        post "/rpc/getproject?select=id,name,client:clients(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
+          [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/rpc/getproject?id=1&select=id,name,client:clients(id),tasks(id)" `shouldRespondWith`
+          [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "cannot embed if the related table is not in the exposed schema" $ do
+        post "/rpc/single_article?select=*,article_stars(*)" [json|{ "id": 1}|]
+          `shouldRespondWith` 400
+        get "/rpc/single_article?id=1&select=*,article_stars(*)"
+          `shouldRespondWith` 400
+
+      it "can embed if the related tables are in a hidden schema but exposed as views" $ do
+        post "/rpc/single_article?select=id,articleStars(userId)"
+            [json|{ "id": 2}|]
+          `shouldRespondWith`
+            [json|{"id": 2, "articleStars": [{"userId": 3}]}|]
+        get "/rpc/single_article?id=2&select=id,articleStars(userId)"
+          `shouldRespondWith`
+            [json|{"id": 2, "articleStars": [{"userId": 3}]}|]
+
+      it "can embed an M2M relationship table" $
+        get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"
+          `shouldRespondWith` [json|[
+            {"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},
+            {"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}
+          ]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "can embed an M2M relationship table that has a parent relationship table" $
+        get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"
+          `shouldRespondWith` [json|[
+            {"name":"Michael Scott","tasks":[
+              {"name":"Design IOS","project":{"name":"IOS"}},
+              {"name":"Code IOS","project":{"name":"IOS"}},
+              {"name":"Design OSX","project":{"name":"OSX"}}
+            ]},
+            {"name":"Dwight Schrute","tasks":[
+              {"name":"Design w7","project":{"name":"Windows 7"}},
+              {"name":"Design IOS","project":{"name":"IOS"}}
+            ]}
+          ]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      when (actualPgVersion >= pgVersion110) $
+        it "can embed if rpc returns domain of table type" $ do
+          post "/rpc/getproject_domain?select=id,name,client:clients(id),tasks(id)"
+              [json| { "id": 1} |]
+            `shouldRespondWith`
+              [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
+          get "/rpc/getproject_domain?id=1&select=id,name,client:clients(id),tasks(id)"
+            `shouldRespondWith`
+              [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
+
+    context "a proc that returns an empty rowset" $
+      it "returns empty json array" $ do
+        post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
+          [json| [] |]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/rpc/test_empty_rowset" `shouldRespondWith`
+          [json| [] |]
+          { matchHeaders = [matchContentTypeJson] }
+
+    context "proc return types" $ do
+      context "returns text" $ do
+        it "returns proper json" $
+          post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
+            [json|"Hello, world"|]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can handle unicode" $
+          post "/rpc/sayhello" [json| { "name": "￥" } |] `shouldRespondWith`
+            [json|"Hello, ￥"|]
+            { matchHeaders = [matchContentTypeJson] }
+
+      it "returns array" $
+        post "/rpc/ret_array" [json|{}|] `shouldRespondWith`
+          [json|[1, 2, 3]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "returns setof integers" $
+        post "/rpc/ret_setof_integers"
+            [json|{}|]
+          `shouldRespondWith`
+            [json|[1,2,3]|]
+
+      it "returns enum value" $
+        post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`
+          [json|"foo"|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "returns domain value" $
+        post "/rpc/ret_domain" [json|{ "val": "8" }|] `shouldRespondWith`
+          [json|8|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "returns range" $
+        post "/rpc/ret_range" [json|{ "low": 10, "up": 20 }|] `shouldRespondWith`
+          [json|"[10,20)"|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "returns row of scalars" $
+        post "/rpc/ret_scalars" [json|{}|] `shouldRespondWith`
+          [json|[{"a":"scalars", "b":"foo", "c":1, "d":"[10,20)"}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "returns composite type in exposed schema" $
+        post "/rpc/ret_point_2d"
+            [json|{}|]
+          `shouldRespondWith`
+            [json|{"x": 10, "y": 5}|]
+
+      it "cannot return composite type in hidden schema" $
+        post "/rpc/ret_point_3d" [json|{}|] `shouldRespondWith` 401
+
+      when (actualPgVersion >= pgVersion110) $
+        it "returns domain of composite type" $
+          post "/rpc/ret_composite_domain"
+              [json|{}|]
+            `shouldRespondWith`
+              [json|{"x": 10, "y": 5}|]
+
+      it "returns single row from table" $
+        post "/rpc/single_article?select=id"
+            [json|{"id": 2}|]
+          `shouldRespondWith`
+            [json|{"id": 2}|]
+
+      it "returns null for void" $
+        post "/rpc/ret_void"
+            [json|{}|]
+          `shouldRespondWith`
+            "null"
+            { matchHeaders = [matchContentTypeJson] }
+
+      it "returns null for an integer with null value" $
+        post "/rpc/ret_null"
+            [json|{}|]
+          `shouldRespondWith`
+            "null"
+            { matchHeaders = [matchContentTypeJson] }
+
+      context "different types when overloaded" $ do
+        it "returns composite type" $
+          post "/rpc/ret_point_overloaded"
+              [json|{"x": 1, "y": 2}|]
+            `shouldRespondWith`
+              [json|{"x": 1, "y": 2}|]
+
+        it "returns json scalar with prefer single object" $
+          request methodPost "/rpc/ret_point_overloaded" [("Prefer","params=single-object")]
+            [json|{"x": 1, "y": 2}|]
+            `shouldRespondWith`
+            [json|{"x": 1, "y": 2}|]
+            { matchHeaders = [matchContentTypeJson] }
+
+    context "proc argument types" $ do
+      -- different syntax for array needed for pg<10
+      when (actualPgVersion < pgVersion100) $
+        it "accepts a variety of arguments (Postgres < 10)" $
+          post "/rpc/varied_arguments"
+              [json| {
+                "double": 3.1,
+                "varchar": "hello",
+                "boolean": true,
+                "date": "20190101",
+                "money": 0,
+                "enum": "foo",
+                "arr": "{a,b,c}",
+                "integer": 43,
+                "json": {"some key": "some value"},
+                "jsonb": {"another key": [1, 2, "3"]}
+              } |]
+            `shouldRespondWith`
+              [json| {
+                "double": 3.1,
+                "varchar": "hello",
+                "boolean": true,
+                "date": "2019-01-01",
+                "money": "$0.00",
+                "enum": "foo",
+                "arr": ["a", "b", "c"],
+                "integer": 43,
+                "json": {"some key": "some value"},
+                "jsonb": {"another key": [1, 2, "3"]}
+              } |]
+              { matchHeaders = [matchContentTypeJson] }
+
+      when (actualPgVersion >= pgVersion100) $
+        it "accepts a variety of arguments (Postgres >= 10)" $
+          post "/rpc/varied_arguments"
+              [json| {
+                "double": 3.1,
+                "varchar": "hello",
+                "boolean": true,
+                "date": "20190101",
+                "money": 0,
+                "enum": "foo",
+                "arr": ["a", "b", "c"],
+                "integer": 43,
+                "json": {"some key": "some value"},
+                "jsonb": {"another key": [1, 2, "3"]}
+              } |]
+            `shouldRespondWith`
+              [json| {
+                "double": 3.1,
+                "varchar": "hello",
+                "boolean": true,
+                "date": "2019-01-01",
+                "money": "$0.00",
+                "enum": "foo",
+                "arr": ["a", "b", "c"],
+                "integer": 43,
+                "json": {"some key": "some value"},
+                "jsonb": {"another key": [1, 2, "3"]}
+              } |]
+              { matchHeaders = [matchContentTypeJson] }
+
+      it "accepts a variety of arguments with GET" $
+        -- without JSON / JSONB here, because passing those via query string is useless - they just become a "json string" all the time
+        get "/rpc/varied_arguments?double=3.1&varchar=hello&boolean=true&date=20190101&money=0&enum=foo&arr=%7Ba,b,c%7D&integer=43"
+          `shouldRespondWith`
+              [json| {
+                "double": 3.1,
+                "varchar": "hello",
+                "boolean": true,
+                "date": "2019-01-01",
+                "money": "$0.00",
+                "enum": "foo",
+                "arr": ["a", "b", "c"],
+                "integer": 43,
+                "json": {},
+                "jsonb": {}
+              } |]
+            { matchHeaders = [matchContentTypeJson] }
+
+      it "accepts a variety of arguments from an html form" $
+        request methodPost "/rpc/varied_arguments"
+            [("Content-Type", "application/x-www-form-urlencoded")]
+            "double=3.1&varchar=hello&boolean=true&date=20190101&money=0&enum=foo&arr=%7Ba,b,c%7D&integer=43"
+          `shouldRespondWith`
+              [json| {
+                "double": 3.1,
+                "varchar": "hello",
+                "boolean": true,
+                "date": "2019-01-01",
+                "money": "$0.00",
+                "enum": "foo",
+                "arr": ["a", "b", "c"],
+                "integer": 43,
+                "json": {},
+                "jsonb": {}
+              } |]
+            { matchHeaders = [matchContentTypeJson] }
+
+      it "parses embedded JSON arguments as JSON" $
+        post "/rpc/json_argument"
+            [json| { "arg": { "key": 3 } } |]
+          `shouldRespondWith`
+            [json|"object"|]
+            { matchHeaders = [matchContentTypeJson] }
+
+      when (actualPgVersion < pgVersion100) $
+        it "parses quoted JSON arguments as JSON (Postgres < 10)" $
+          post "/rpc/json_argument"
+              [json| { "arg": "{ \"key\": 3 }" } |]
+            `shouldRespondWith`
+              [json|"object"|]
+              { matchHeaders = [matchContentTypeJson] }
+
+      when ((actualPgVersion >= pgVersion109 && actualPgVersion < pgVersion110)
+            || actualPgVersion >= pgVersion114) $
+        it "parses quoted JSON arguments as JSON string (from Postgres 10.9, 11.4)" $
+          post "/rpc/json_argument"
+              [json| { "arg": "{ \"key\": 3 }" } |]
+            `shouldRespondWith`
+              [json|"string"|]
+              { matchHeaders = [matchContentTypeJson] }
+
+    context "improper input" $ do
+      it "rejects unknown content type even if payload is good" $ do
+        request methodPost "/rpc/sayhello"
+          (acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]
+            `shouldRespondWith` 415
+        request methodGet "/rpc/sayhello?name=world"
+          (acceptHdrs "audio/mpeg3") ""
+            `shouldRespondWith` 415
+      it "rejects malformed json payload" $ do
+        p <- request methodPost "/rpc/sayhello"
+          (acceptHdrs "application/json") "sdfsdf"
+        liftIO $ do
+          simpleStatus p `shouldBe` badRequest400
+          isErrorFormat (simpleBody p) `shouldBe` True
+      it "treats simple plpgsql raise as invalid input" $ do
+        p <- post "/rpc/problem" "{}"
+        liftIO $ do
+          simpleStatus p `shouldBe` badRequest400
+          isErrorFormat (simpleBody p) `shouldBe` True
+      it "treats plpgsql assert as internal server error" $ do
+        p <- post "/rpc/assert" "{}"
+        liftIO $ do
+          simpleStatus p `shouldBe` internalServerError500
+          isErrorFormat (simpleBody p) `shouldBe` True
+
+    context "unsupported verbs" $ do
+      it "DELETE fails" $
+        request methodDelete "/rpc/sayhello" [] ""
+          `shouldRespondWith`
+          [json|{"message":"Bad Request"}|]
+          { matchStatus  = 405
+          , matchHeaders = [matchContentTypeJson]
+          }
+      it "PATCH fails" $
+        request methodPatch "/rpc/sayhello" [] ""
+          `shouldRespondWith` 405
+      it "OPTIONS fails" $
+        -- TODO: should return info about the function
+        request methodOptions "/rpc/sayhello" [] ""
+          `shouldRespondWith` 405
+
+    it "executes the proc exactly once per request" $ do
+      -- callcounter is persistent even with rollback, because it uses a sequence
+      -- reset counter first to make test repeatable
+      request methodPost "/rpc/reset_sequence"
+          [("Prefer", "tx=commit")]
+          [json|{"name": "callcounter_count", "value": 1}|]
+        `shouldRespondWith`
+          [json|""|]
+
+      -- now the test
+      post "/rpc/callcounter"
+          [json|{}|]
+        `shouldRespondWith`
+          [json|1|]
+
+      post "/rpc/callcounter"
+          [json|{}|]
+        `shouldRespondWith`
+          [json|2|]
+
+    context "a proc that receives no parameters" $ do
+      it "interprets empty string as empty json object on a post request" $
+        post "/rpc/noparamsproc" BL.empty `shouldRespondWith`
+          [json| "Return value of no parameters procedure." |]
+          { matchHeaders = [matchContentTypeJson] }
+      it "interprets empty string as a function with no args on a get request" $
+        get "/rpc/noparamsproc" `shouldRespondWith`
+          [json| "Return value of no parameters procedure." |]
+          { matchHeaders = [matchContentTypeJson] }
+
+    it "returns proper output when having the same return col name as the proc name" $ do
+      post "/rpc/test" [json|{}|] `shouldRespondWith`
+        [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }
+      get "/rpc/test" `shouldRespondWith`
+        [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }
+
+    context "procs with OUT/INOUT params" $ do
+      it "returns an object result when there is a single OUT param" $ do
+        get "/rpc/single_out_param?num=5"
+          `shouldRespondWith`
+            [json|{"num_plus_one":6}|]
+
+        get "/rpc/single_json_out_param?a=1&b=two"
+          `shouldRespondWith`
+            [json|{"my_json": {"a": 1, "b": "two"}}|]
+
+      it "returns an object result when there is a single INOUT param" $
+        get "/rpc/single_inout_param?num=2"
+          `shouldRespondWith`
+            [json|{"num":3}|]
+
+      it "returns an object result when there are many OUT params" $
+        get "/rpc/many_out_params"
+          `shouldRespondWith`
+            [json|{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}|]
+
+      it "returns an object result when there are many INOUT params" $
+        get "/rpc/many_inout_params?num=1&str=two&b=false"
+          `shouldRespondWith`
+            [json|{"num":1,"str":"two","b":false}|]
+
+    context "procs with TABLE return" $ do
+      it "returns an object result when there is a single-column TABLE return type" $
+        get "/rpc/single_column_table_return"
+          `shouldRespondWith`
+            [json|[{"a": "A"}]|]
+
+      it "returns an object result when there is a multi-column TABLE return type" $
+        get "/rpc/multi_column_table_return"
+          `shouldRespondWith`
+            [json|[{"a": "A", "b": "B"}]|]
+
+    context "procs with VARIADIC params" $ do
+      when (actualPgVersion < pgVersion100) $
+        it "works with POST (Postgres < 10)" $
+          post "/rpc/variadic_param"
+              [json| { "v": "{hi,hello,there}" } |]
+            `shouldRespondWith`
+              [json|["hi", "hello", "there"]|]
+
+      when (actualPgVersion >= pgVersion100) $ do
+        it "works with POST (Postgres >= 10)" $
+          post "/rpc/variadic_param"
+              [json| { "v": ["hi", "hello", "there"] } |]
+            `shouldRespondWith`
+              [json|["hi", "hello", "there"]|]
+
+        context "works with GET and repeated params" $ do
+          it "n=0 (through DEFAULT)" $
+            get "/rpc/variadic_param"
+              `shouldRespondWith`
+                [json|[]|]
+
+          it "n=1" $
+            get "/rpc/variadic_param?v=hi"
+              `shouldRespondWith`
+                [json|["hi"]|]
+
+          it "n>1" $
+            get "/rpc/variadic_param?v=hi&v=there"
+              `shouldRespondWith`
+                [json|["hi", "there"]|]
+
+        context "works with POST and repeated params from html form" $ do
+          it "n=0 (through DEFAULT)" $
+            request methodPost "/rpc/variadic_param"
+                [("Content-Type", "application/x-www-form-urlencoded")]
+                ""
+              `shouldRespondWith`
+                [json|[]|]
+
+          it "n=1" $
+            request methodPost "/rpc/variadic_param"
+                [("Content-Type", "application/x-www-form-urlencoded")]
+                "v=hi"
+              `shouldRespondWith`
+                [json|["hi"]|]
+
+          it "n>1" $
+            request methodPost "/rpc/variadic_param"
+                [("Content-Type", "application/x-www-form-urlencoded")]
+                "v=hi&v=there"
+              `shouldRespondWith`
+                [json|["hi", "there"]|]
+
+    it "returns last value for repeated params without VARIADIC" $
+      get "/rpc/sayhello?name=ignored&name=world"
+        `shouldRespondWith`
+          [json|"Hello, world"|]
+
+    when (actualPgVersion >= pgVersion100) $
+      it "returns last value for repeated non-variadic params in function with other VARIADIC arguments" $
+        get "/rpc/sayhello_variadic?name=ignored&name=world&v=unused"
+          `shouldRespondWith`
+            [json|"Hello, world"|]
+
+    it "can handle procs with args that have a DEFAULT value" $ do
+      get "/rpc/many_inout_params?num=1&str=two"
+        `shouldRespondWith`
+          [json| {"num":1,"str":"two","b":true}|]
+      get "/rpc/three_defaults?b=4"
+        `shouldRespondWith`
+          [json|8|]
+
+    it "can map a RAISE error code and message to a http status" $
+      get "/rpc/raise_pt402"
+        `shouldRespondWith` [json|{ "hint": "Upgrade your plan", "details": "Quota exceeded" }|]
+        { matchStatus  = 402
+        , matchHeaders = [matchContentTypeJson]
+        }
+
+    it "defaults to status 500 if RAISE code is PT not followed by a number" $
+      get "/rpc/raise_bad_pt"
+        `shouldRespondWith`
+        [json|{"hint": null, "details": null}|]
+        { matchStatus  = 500
+        , matchHeaders = [ matchContentTypeJson ]
+        }
+
+    context "expects a single json object" $ do
+      it "does not expand posted json into parameters" $
+        request methodPost "/rpc/singlejsonparam"
+          [("prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
+          [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "accepts parameters from an html form" $
+        request methodPost "/rpc/singlejsonparam"
+          [("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]
+          ("integer=7&double=2.71828&varchar=forms+are+fun&" <>
+           "boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
+          [json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
+                 , "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
+                 { matchHeaders = [matchContentTypeJson] }
+
+      it "works with GET" $
+        request methodGet "/rpc/singlejsonparam?p1=1&p2=text" [("Prefer","params=single-object")] ""
+          `shouldRespondWith` [json|{ "p1": "1", "p2": "text"}|]
+          { matchHeaders = [matchContentTypeJson] }
+
+    context "should work with an overloaded function" $ do
+      it "overloaded()" $
+        get "/rpc/overloaded"
+          `shouldRespondWith`
+            [json|[1,2,3]|]
+
+      it "overloaded(json) single-object" $
+        request methodPost "/rpc/overloaded"
+            [("Prefer","params=single-object")]
+            [json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
+          `shouldRespondWith`
+            [json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
+
+      it "overloaded(int, int)" $
+        get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]
+
+      it "overloaded(text, text, text)" $
+        get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [json|"123"|]
+
+      it "overloaded_html_form()" $
+        request methodPost "/rpc/overloaded_html_form"
+            [("Content-Type", "application/x-www-form-urlencoded")]
+            ""
+          `shouldRespondWith`
+            [json|[1,2,3]|]
+
+      it "overloaded_html_form(json) single-object" $
+        request methodPost "/rpc/overloaded_html_form"
+            [("Content-Type", "application/x-www-form-urlencoded"), ("Prefer","params=single-object")]
+            "a=1&b=2&c=3"
+          `shouldRespondWith`
+            [json|{"a": "1", "b": "2", "c": "3"}|]
+
+      it "overloaded_html_form(int, int)" $
+        request methodPost "/rpc/overloaded_html_form"
+            [("Content-Type", "application/x-www-form-urlencoded")]
+            "a=1&b=2"
+          `shouldRespondWith`
+            [str|3|]
+
+      it "overloaded_html_form(text, text, text)" $
+        request methodPost "/rpc/overloaded_html_form"
+            [("Content-Type", "application/x-www-form-urlencoded")]
+            "a=1&b=2&c=3"
+          `shouldRespondWith`
+            [json|"123"|]
+
+      -- https://github.com/PostgREST/postgrest/issues/1672
+      context "embedding overloaded functions with the same signature except for the last param with a default value" $ do
+        it "overloaded_default(text default)" $ do
+          request methodPost "/rpc/overloaded_default?select=id,name,users(name)"
+              [("Content-Type", "application/json")]
+              [json|{}|]
+            `shouldRespondWith`
+              [json|[{"id": 2, "name": "Code w7", "users": [{"name": "Angela Martin"}]}] |]
+
+        it "overloaded_default(int)" $
+          request methodPost "/rpc/overloaded_default"
+              [("Content-Type", "application/json")]
+              [json|{"must_param":1}|]
+            `shouldRespondWith`
+              [json|{"val":1}|]
+
+        it "overloaded_default(int, text default)" $ do
+          request methodPost "/rpc/overloaded_default?select=id,name,users(name)"
+              [("Content-Type", "application/json")]
+              [json|{"a":4}|]
+            `shouldRespondWith`
+              [json|[{"id": 5, "name": "Design IOS", "users": [{"name": "Michael Scott"}, {"name": "Dwight Schrute"}]}] |]
+
+        it "overloaded_default(int, int)" $
+          request methodPost "/rpc/overloaded_default"
+              [("Content-Type", "application/json")]
+              [json|{"a":2,"must_param":4}|]
+            `shouldRespondWith`
+              [json|{"a":2,"val":4}|]
+
+    context "only for POST rpc" $ do
+      it "gives a parse filter error if GET style proc args are specified" $
+        post "/rpc/sayhello?name=John" [json|{name: "John"}|] `shouldRespondWith` 400
+
+      it "ignores json keys not included in ?columns" $
+        post "/rpc/sayhello?columns=name"
+          [json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|]
+          `shouldRespondWith`
+          [json|"Hello, John"|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "only takes the first object in case of array of objects payload" $
+        post "/rpc/add_them"
+          [json|[
+            {"a": 1, "b": 2},
+            {"a": 4, "b": 6},
+            {"a": 100, "b": 200} ]|]
+          `shouldRespondWith` "3"
+          { matchHeaders = [matchContentTypeJson] }
+
+    context "bulk RPC with params=multiple-objects" $ do
+      it "works with a scalar function an returns a json array" $
+        request methodPost "/rpc/add_them" [("Prefer", "params=multiple-objects")]
+          [json|[
+            {"a": 1, "b": 2},
+            {"a": 4, "b": 6},
+            {"a": 100, "b": 200} ]|]
+          `shouldRespondWith`
+          [json|
+            [3, 10, 300]
+          |] { matchHeaders = [matchContentTypeJson] }
+
+      it "works with a scalar function an returns a json array when posting CSV" $
+        request methodPost "/rpc/add_them" [("Content-Type", "text/csv"), ("Prefer", "params=multiple-objects")]
+          "a,b\n1,2\n4,6\n100,200"
+          `shouldRespondWith`
+          [json|
+            [3, 10, 300]
+          |]
+          { matchStatus  = 200
+          , matchHeaders = [matchContentTypeJson]
+          }
+
+      it "works with a non-scalar result" $
+        request methodPost "/rpc/get_projects_below?select=id,name" [("Prefer", "params=multiple-objects")]
+          [json|[
+            {"id": 1},
+            {"id": 5} ]|]
+          `shouldRespondWith`
+          [json|
+            [{"id":1,"name":"Windows 7"},
+             {"id":2,"name":"Windows 10"},
+             {"id":3,"name":"IOS"},
+             {"id":4,"name":"OSX"}]
+          |] { matchHeaders = [matchContentTypeJson] }
+
+    context "HTTP request env vars" $ do
+      it "custom header is set" $
+        request methodPost "/rpc/get_guc_value"
+                  [("Custom-Header", "test")]
+            (
+            if actualPgVersion >= pgVersion140 then
+              [json| { "prefix": "request.headers", "name": "custom-header" } |]
+            else
+              [json| { "name": "request.header.custom-header" } |]
+            )
+            `shouldRespondWith`
+            [json|"test"|]
+            { matchStatus  = 200
+            , matchHeaders = [ matchContentTypeJson ]
+            }
+      it "standard header is set" $
+        request methodPost "/rpc/get_guc_value"
+                  [("Origin", "http://example.com")]
+            (
+            if actualPgVersion >= pgVersion140 then
+              [json| { "prefix": "request.headers", "name": "origin" } |]
+            else
+              [json| { "name": "request.header.origin" } |]
+            )
+            `shouldRespondWith`
+            [json|"http://example.com"|]
+            { matchStatus  = 200
+            , matchHeaders = [ matchContentTypeJson ]
+            }
+      it "current role is available as GUC claim" $
+        request methodPost "/rpc/get_guc_value" []
+            (
+            if actualPgVersion >= pgVersion140 then
+              [json| { "prefix": "request.jwt.claims", "name": "role" } |]
+            else
+              [json| { "name": "request.jwt.claim.role" } |]
+            )
+            `shouldRespondWith`
+            [json|"postgrest_test_anonymous"|]
+            { matchStatus  = 200
+            , matchHeaders = [ matchContentTypeJson ]
+            }
+      it "single cookie ends up as claims" $
+        request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
+          (
+          if actualPgVersion >= pgVersion140 then
+            [json| {"prefix": "request.cookies", "name":"acookie"} |]
+          else
+            [json| {"name":"request.cookie.acookie"} |]
+          )
+            `shouldRespondWith`
+            [json|"cookievalue"|]
+            { matchStatus = 200
+            , matchHeaders = []
+            }
+      it "multiple cookies ends up as claims" $
+        request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
+          (
+          if actualPgVersion >= pgVersion140 then
+            [json| {"prefix": "request.cookies", "name":"secondcookie"} |]
+          else
+            [json| {"name":"request.cookie.secondcookie"} |]
+          )
+            `shouldRespondWith`
+            [json|"anothervalue"|]
+            { matchStatus = 200
+            , matchHeaders = []
+            }
+      it "app settings available" $
+        request methodPost "/rpc/get_guc_value" []
+          [json| { "name": "app.settings.app_host" } |]
+            `shouldRespondWith`
+            [json|"localhost"|]
+            { matchStatus  = 200
+            , matchHeaders = [ matchContentTypeJson ]
+            }
+      it "gets the Authorization value" $
+        request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
+          (
+          if actualPgVersion >= pgVersion140 then
+            [json| {"prefix": "request.headers", "name":"authorization"} |]
+          else
+            [json| {"name":"request.header.authorization"} |]
+          )
+            `shouldRespondWith`
+            [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
+            { matchStatus = 200
+            , matchHeaders = []
+            }
+      it "gets the http method" $
+        request methodPost "/rpc/get_guc_value" []
+          [json| {"name":"request.method"} |]
+            `shouldRespondWith`
+            [json|"POST"|]
+            { matchStatus = 200
+            , matchHeaders = []
+            }
+      it "gets the http path" $
+        request methodPost "/rpc/get_guc_value" []
+          [json| {"name":"request.path"} |]
+            `shouldRespondWith`
+            [json|"/rpc/get_guc_value"|]
+            { matchStatus = 200
+            , matchHeaders = []
+            }
+
+    context "binary output" $ do
+      context "Proc that returns scalar" $ do
+        it "can query without selecting column" $
+          request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") ""
+            `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
+            { matchStatus = 200
+            , matchHeaders = ["Content-Type" <:> "application/octet-stream"]
+            }
+
+        it "can get raw output with Accept: text/plain" $
+          request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
+            `shouldRespondWith` "Welcome to PostgREST"
+            { matchStatus = 200
+            , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
+            }
+
+      context "Proc that returns set of scalars" $
+        it "can query without selecting column" $
+          request methodGet "/rpc/welcome_twice"
+              (acceptHdrs "text/plain")
+              ""
+            `shouldRespondWith`
+              "Welcome to PostgRESTWelcome to PostgREST"
+              { matchStatus = 200
+              , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
+              }
+
+      context "Proc that returns rows" $ do
+        it "can query if a single column is selected" $
+          request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (acceptHdrs "application/octet-stream") ""
+            `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
+            { matchStatus = 200
+            , matchHeaders = ["Content-Type" <:> "application/octet-stream"]
+            }
+
+        it "fails if a single column is not selected" $
+          request methodPost "/rpc/ret_rows_with_base64_bin"
+              (acceptHdrs "application/octet-stream") ""
+            `shouldRespondWith`
+              [json| {"message":"application/octet-stream requested but more than one column was selected"} |]
+              { matchStatus = 406 }
+
+    context "only for GET rpc" $ do
+      it "should fail on mutating procs" $ do
+        get "/rpc/callcounter" `shouldRespondWith` 405
+        get "/rpc/setprojects?id_l=1&id_h=5&name=FreeBSD" `shouldRespondWith` 405
+
+      it "should filter a proc that has arg name = filter name" $
+        get "/rpc/get_projects_below?id=5&id=gt.2&select=id" `shouldRespondWith`
+          [json|[{ "id": 3 }, { "id": 4 }]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "should work with filters that have the not operator" $ do
+        get "/rpc/get_projects_below?id=5&id=not.gt.2&select=id" `shouldRespondWith`
+          [json|[{ "id": 1 }, { "id": 2 }]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/rpc/get_projects_below?id=5&id=not.in.(1,3)&select=id" `shouldRespondWith`
+          [json|[{ "id": 2 }, { "id": 4 }]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+      it "should work with filters that use the plain with language fts operator" $ do
+        get "/rpc/get_tsearch?text_search_vector=fts(english).impossible" `shouldRespondWith`
+          [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/rpc/get_tsearch?text_search_vector=plfts.impossible" `shouldRespondWith`
+          [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`
+          [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
+          { matchHeaders = [matchContentTypeJson] }
+        when (actualPgVersion >= pgVersion112) $
+            get "/rpc/get_tsearch?text_search_vector=wfts.impossible" `shouldRespondWith`
+                [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
+                { matchHeaders = [matchContentTypeJson] }
+
+      it "should work with the phraseto_tsquery function" $
+        get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith`
+          [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
+          { matchHeaders = [matchContentTypeJson] }
+
+    it "should work with an argument of custom type in public schema" $
+        get "/rpc/test_arg?my_arg=something" `shouldRespondWith`
+          [json|"foobar"|]
+          { matchHeaders = [matchContentTypeJson] }
+
+    context "GUC headers on function calls" $ do
+      it "succeeds setting the headers" $ do
+        get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"
+          `shouldRespondWith` [json|[{"id": 2}]|]
+          {matchHeaders = [
+              matchContentTypeJson,
+              "X-Test"   <:> "key1=val1; someValue; key2=val2",
+              "X-Test-2" <:> "key1=val1"]}
+        get "/rpc/get_int_and_guc_headers?num=1"
+          `shouldRespondWith` [json|1|]
+          {matchHeaders = [
+              matchContentTypeJson,
+              "X-Test"   <:> "key1=val1; someValue; key2=val2",
+              "X-Test-2" <:> "key1=val1"]}
+        post "/rpc/get_int_and_guc_headers" [json|{"num": 1}|]
+          `shouldRespondWith` [json|1|]
+          {matchHeaders = [
+              matchContentTypeJson,
+              "X-Test"   <:> "key1=val1; someValue; key2=val2",
+              "X-Test-2" <:> "key1=val1"]}
+
+      it "fails when setting headers with wrong json structure" $ do
+        get "/rpc/bad_guc_headers_1"
+          `shouldRespondWith`
+          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
+          { matchStatus  = 500
+          , matchHeaders = [ matchContentTypeJson ]
+          }
+        get "/rpc/bad_guc_headers_2"
+          `shouldRespondWith`
+          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
+          { matchStatus  = 500
+          , matchHeaders = [ matchContentTypeJson ]
+          }
+        get "/rpc/bad_guc_headers_3"
+          `shouldRespondWith`
+          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
+          { matchStatus  = 500
+          , matchHeaders = [ matchContentTypeJson ]
+          }
+        post "/rpc/bad_guc_headers_1" [json|{}|]
+          `shouldRespondWith`
+          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
+          { matchStatus  = 500
+          , matchHeaders = [ matchContentTypeJson ]
+          }
+
+      it "can set the same http header twice" $
+        get "/rpc/set_cookie_twice"
+          `shouldRespondWith`
+            "null"
+            { matchHeaders = [ matchContentTypeJson
+                             , "Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/"
+                             , "Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly" ]}
+
+      it "can override the Location header on a trigger" $
+        post "/stuff"
+            [json|[{"id": 2, "name": "stuff 2"}]|]
+          `shouldRespondWith`
+            ""
+            { matchStatus = 201
+            , matchHeaders = ["Location" <:> "/stuff?id=eq.2&overriden=true"]
+            }
+
+      -- On https://github.com/PostgREST/postgrest/issues/1427#issuecomment-595907535
+      -- it was reported that blank headers ` : ` where added and that cause proxies to fail the requests.
+      -- These tests are to ensure no blank headers are added.
+      context "Blank headers bug" $ do
+        it "shouldn't add blank headers on POST" $ do
+          r <- request methodPost "/loc_test" [] [json|{"id": "1", "c": "c1"}|]
+          liftIO $ do
+            let respHeaders = simpleHeaders r
+            respHeaders `shouldSatisfy` noBlankHeader
+
+        it "shouldn't add blank headers on PATCH" $ do
+          r <- request methodPatch "/loc_test?id=eq.1" [] [json|{"c": "c2"}|]
+          liftIO $ do
+            let respHeaders = simpleHeaders r
+            respHeaders `shouldSatisfy` noBlankHeader
+
+        it "shouldn't add blank headers on GET" $ do
+          r <- request methodGet "/loc_test" [] ""
+          liftIO $ do
+            let respHeaders = simpleHeaders r
+            respHeaders `shouldSatisfy` noBlankHeader
+
+        it "shouldn't add blank headers on DELETE" $ do
+          r <- request methodDelete "/loc_test?id=eq.1" [] ""
+          liftIO $ do
+            let respHeaders = simpleHeaders r
+            respHeaders `shouldSatisfy` noBlankHeader
+
+      context "GUC status override" $ do
+        it "can override the status on RPC" $
+          get "/rpc/send_body_status_403"
+            `shouldRespondWith`
+            [json|{"message" : "invalid user or password"}|]
+            { matchStatus  = 403
+            , matchHeaders = [ matchContentTypeJson ]
+            }
+
+        it "can override the status through trigger" $
+          patch "/stuff?id=eq.1"
+              [json|[{"name": "updated stuff 1"}]|]
+            `shouldRespondWith`
+              205
+
+        it "fails when setting invalid status guc" $
+          get "/rpc/send_bad_status"
+            `shouldRespondWith`
+            [json|{"message":"response.status guc must be a valid status code"}|]
+            { matchStatus  = 500
+            , matchHeaders = [ matchContentTypeJson ]
+            }
+
+      context "single unnamed param" $ do
+        it "can insert json directly" $
+          post "/rpc/unnamed_json_param"
+              [json|{"A": 1, "B": 2, "C": 3}|]
+            `shouldRespondWith`
+              [json|{"A": 1, "B": 2, "C": 3}|]
+
+        it "can insert text directly" $
+          request methodPost "/rpc/unnamed_text_param"
+            [("Content-Type", "text/plain"), ("Accept", "text/plain")]
+            [str|unnamed text arg|]
+            `shouldRespondWith`
+            [str|unnamed text arg|]
+
+        it "can insert bytea directly" $ do
+          let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png"
+          r <- request methodPost "/rpc/unnamed_bytea_param"
+            [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
+            file
+          liftIO $ do
+            let respBody = simpleBody r
+            respBody `shouldBe` file
+
+        it "will err when no function with single unnamed json parameter exists and application/json is specified" $
+          request methodPost "/rpc/unnamed_int_param" [("Content-Type", "application/json")]
+              [json|{"x": 1, "y": 2}|]
+            `shouldRespondWith`
+              [json|{
+                "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
+                "message": "Could not find the test.unnamed_int_param(x, y) function or the test.unnamed_int_param function with a single unnamed json or jsonb parameter in the schema cache"
+              }|]
+              { matchStatus  = 404
+              , matchHeaders = [ matchContentTypeJson ]
+              }
+
+        it "will err when no function with single unnamed text parameter exists and text/plain is specified" $
+          request methodPost "/rpc/unnamed_int_param"
+              [("Content-Type", "text/plain")]
+              [str|a simple text|]
+            `shouldRespondWith`
+              [json|{
+                "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
+                "message": "Could not find the test.unnamed_int_param function with a single unnamed text parameter in the schema cache"
+              }|]
+              { matchStatus  = 404
+              , matchHeaders = [ matchContentTypeJson ]
+              }
+
+        it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $
+          let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png" in
+          request methodPost "/rpc/unnamed_int_param"
+              [("Content-Type", "application/octet-stream")]
+              file
+          `shouldRespondWith`
+            [json|{
+              "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",
+              "message": "Could not find the test.unnamed_int_param function with a single unnamed bytea parameter in the schema cache"
+            }|]
+            { matchStatus  = 404
+            , matchHeaders = [ matchContentTypeJson ]
+            }
+
+        it "should be able to resolve when a single unnamed json parameter exists and other overloaded functions are found" $ do
+          request methodPost "/rpc/overloaded_unnamed_param" [("Content-Type", "application/json")]
+              [json|{}|]
+            `shouldRespondWith`
+              [json| 1 |]
+              { matchStatus  = 200
+              , matchHeaders = [matchContentTypeJson]
+              }
+          request methodPost "/rpc/overloaded_unnamed_param" [("Content-Type", "application/json")]
+              [json|{"x": 1, "y": 2}|]
+            `shouldRespondWith`
+              [json| 3 |]
+              { matchStatus  = 200
+              , matchHeaders = [matchContentTypeJson]
+              }
+
+        it "should be able to fallback to the single unnamed parameter function when other overloaded functions are not found" $ do
+          request methodPost "/rpc/overloaded_unnamed_param"
+              [("Content-Type", "application/json")]
+              [json|{"A": 1, "B": 2, "C": 3}|]
+            `shouldRespondWith`
+              [json|{"A": 1, "B": 2, "C": 3}|]
+          request methodPost "/rpc/overloaded_unnamed_param"
+              [("Content-Type", "text/plain"), ("Accept", "text/plain")]
+              [str|unnamed text arg|]
+            `shouldRespondWith`
+              [str|unnamed text arg|]
+          let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png"
+          r <- request methodPost "/rpc/overloaded_unnamed_param"
+            [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
+            file
+          liftIO $ do
+            let respBody = simpleBody r
+            respBody `shouldBe` file
+
+        it "should call the function with no parameters and not fallback to the single unnamed parameter function when using GET with Content-Type headers" $ do
+          request methodGet "/rpc/overloaded_unnamed_param" [("Content-Type", "text/plain")] ""
+            `shouldRespondWith`
+              [json| 1|]
+              { matchStatus  = 200 }
+          request methodGet "/rpc/overloaded_unnamed_param" [("Content-Type", "application/octet-stream")] ""
+            `shouldRespondWith`
+              [json| 1|]
+              { matchStatus  = 200 }
+
+        it "should fail to fallback to any single unnamed parameter function when using an unsupported Content-Type header" $ do
+          request methodPost "/rpc/overloaded_unnamed_param"
+              [("Content-Type", "text/csv")]
+              "a,b\n1,2\n4,6\n100,200"
+            `shouldRespondWith`
+              [json| {
+                "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",
+                "message":"Could not find the test.overloaded_unnamed_param(a, b) function in the schema cache"}|]
+              { matchStatus  = 404
+              , matchHeaders = [matchContentTypeJson]
+              }
+
+        it "should fail with multiple choices when two fallback functions with single unnamed json and jsonb parameters exist" $ do
+          request methodPost "/rpc/overloaded_unnamed_json_jsonb_param" [("Content-Type", "application/json")]
+              [json|{"A": 1, "B": 2, "C": 3}|]
+            `shouldRespondWith`
+              [json| {
+                "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved",
+                "message":"Could not choose the best candidate function between: test.overloaded_unnamed_json_jsonb_param( => json), test.overloaded_unnamed_json_jsonb_param( => jsonb)"}|]
+              { matchStatus  = 300
+              , matchHeaders = [matchContentTypeJson]
+              }
diff --git a/test/spec/Feature/SingularSpec.hs b/test/spec/Feature/SingularSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/SingularSpec.hs
@@ -0,0 +1,324 @@
+module Feature.SingularSpec where
+
+import Network.Wai      (Application)
+import Network.Wai.Test (SResponse (..))
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude  hiding (get)
+import SpecHelper
+
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "Requesting singular json object" $ do
+    let singular = ("Accept", "application/vnd.pgrst.object+json")
+
+    context "with GET request" $ do
+      it "fails for zero rows" $
+        request methodGet  "/items?id=gt.0&id=lt.0" [singular] ""
+          `shouldRespondWith` 406
+
+      it "will select an existing object" $ do
+        request methodGet "/items?id=eq.5" [singular] ""
+          `shouldRespondWith`
+            [json|{"id":5}|]
+            { matchHeaders = [matchContentTypeSingular] }
+        -- also test without the +json suffix
+        request methodGet "/items?id=eq.5"
+            [("Accept", "application/vnd.pgrst.object")] ""
+          `shouldRespondWith`
+            [json|{"id":5}|]
+            { matchHeaders = [matchContentTypeSingular] }
+
+      it "can combine multiple prefer values" $
+        request methodGet "/items?id=eq.5" [singular, ("Prefer","count=none")] ""
+          `shouldRespondWith`
+            [json|{"id":5}|]
+            { matchHeaders = [matchContentTypeSingular] }
+
+      it "can shape plurality singular object routes" $
+        request methodGet "/projects_view?id=eq.1&select=id,name,clients(*),tasks(id,name)" [singular] ""
+          `shouldRespondWith`
+            [json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]
+            { matchHeaders = [matchContentTypeSingular] }
+
+    context "when updating rows" $ do
+      it "works for one row with return=rep" $ do
+        request methodPatch "/addresses?id=eq.1"
+            [("Prefer", "return=representation"), singular]
+            [json| { address: "B Street" } |]
+          `shouldRespondWith`
+            [json|{"id":1,"address":"B Street"}|]
+            { matchHeaders = [matchContentTypeSingular] }
+
+      it "works for one row with return=minimal" $
+        request methodPatch "/addresses?id=eq.1"
+            [("Prefer", "return=minimal"), singular]
+            [json| { address: "C Street" } |]
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 204 }
+
+      it "raises an error for multiple rows" $ do
+        request methodPatch "/addresses"
+            [("Prefer", "tx=commit"), singular]
+            [json| { address: "zzz" } |]
+          `shouldRespondWith`
+            [json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+            { matchStatus  = 406
+            , matchHeaders = [ matchContentTypeSingular
+                             , "Preference-Applied" <:> "tx=commit" ]
+            }
+
+        -- the rows should not be updated, either
+        get "/addresses?id=eq.1"
+          `shouldRespondWith`
+            [json|[{"id":1,"address":"address 1"}]|]
+
+      it "raises an error for multiple rows with return=rep" $ do
+        request methodPatch "/addresses"
+            [("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]
+            [json| { address: "zzz" } |]
+          `shouldRespondWith`
+            [json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+            { matchStatus  = 406
+            , matchHeaders = [ matchContentTypeSingular
+                             , "Preference-Applied" <:> "tx=commit" ]
+            }
+
+        -- the rows should not be updated, either
+        get "/addresses?id=eq.1"
+          `shouldRespondWith`
+            [json|[{"id":1,"address":"address 1"}]|]
+
+      it "raises an error for zero rows" $
+        request methodPatch "/items?id=gt.0&id=lt.0"
+                [singular] [json|{"id":1}|]
+          `shouldRespondWith`
+                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+                  { matchStatus  = 406
+                  , matchHeaders = [matchContentTypeSingular]
+                  }
+
+      it "raises an error for zero rows with return=rep" $
+        request methodPatch "/items?id=gt.0&id=lt.0"
+                [("Prefer", "return=representation"), singular] [json|{"id":1}|]
+          `shouldRespondWith`
+                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+                  { matchStatus  = 406
+                  , matchHeaders = [matchContentTypeSingular]
+                  }
+
+    context "when creating rows" $ do
+      it "works for one row with return=rep" $ do
+        request methodPost "/addresses"
+            [("Prefer", "return=representation"), singular]
+            [json| [ { id: 102, address: "xxx" } ] |]
+          `shouldRespondWith`
+            [json|{"id":102,"address":"xxx"}|]
+            { matchStatus  = 201
+            , matchHeaders = [matchContentTypeSingular]
+            }
+
+      it "works for one row with return=minimal" $ do
+        request methodPost "/addresses"
+            [("Prefer", "return=minimal"), singular]
+            [json| [ { id: 103, address: "xxx" } ] |]
+          `shouldRespondWith` ""
+            { matchStatus  = 201
+            , matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+      it "raises an error when attempting to create multiple entities" $ do
+        request methodPost "/addresses"
+            [("Prefer", "tx=commit"), singular]
+            [json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
+          `shouldRespondWith`
+            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+            { matchStatus  = 406
+            , matchHeaders = [ matchContentTypeSingular
+                             , "Preference-Applied" <:> "tx=commit" ]
+            }
+
+        -- the rows should not exist, either
+        get "/addresses?id=eq.200"
+          `shouldRespondWith`
+            "[]"
+
+      it "raises an error when attempting to create multiple entities with return=rep" $ do
+        request methodPost "/addresses"
+            [("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]
+            [json| [ { id: 202, address: "xxx" }, { id: 203, address: "yyy" } ] |]
+          `shouldRespondWith`
+            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+            { matchStatus  = 406
+            , matchHeaders = [ matchContentTypeSingular
+                             , "Preference-Applied" <:> "tx=commit" ]
+            }
+
+        -- the rows should not exist, either
+        get "/addresses?id=eq.202"
+          `shouldRespondWith`
+            "[]"
+
+      it "raises an error regardless of return=minimal" $ do
+        request methodPost "/addresses"
+            [("Prefer", "tx=commit"), ("Prefer", "return=minimal"), singular]
+            [json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]
+          `shouldRespondWith`
+            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+            { matchStatus  = 406
+            , matchHeaders = [ matchContentTypeSingular
+                             , "Preference-Applied" <:> "tx=commit" ]
+            }
+
+        -- the rows should not exist, either
+        get "/addresses?id=eq.204"
+          `shouldRespondWith`
+            "[]"
+
+      it "raises an error when creating zero entities" $
+        request methodPost "/addresses"
+                [singular]
+                [json| [ ] |]
+          `shouldRespondWith`
+                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+                  { matchStatus  = 406
+                  , matchHeaders = [matchContentTypeSingular]
+                  }
+
+      it "raises an error when creating zero entities with return=rep" $
+        request methodPost "/addresses"
+                [("Prefer", "return=representation"), singular]
+                [json| [ ] |]
+          `shouldRespondWith`
+                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+                  { matchStatus  = 406
+                  , matchHeaders = [matchContentTypeSingular]
+                  }
+
+    context "when deleting rows" $ do
+      it "works for one row with return=rep" $ do
+        p <- request methodDelete
+          "/items?id=eq.11"
+          [("Prefer", "return=representation"), singular] ""
+        liftIO $ simpleBody p `shouldBe` [json|{"id":11}|]
+
+      it "works for one row with return=minimal" $ do
+        p <- request methodDelete
+          "/items?id=eq.12"
+          [("Prefer", "return=minimal"), singular] ""
+        liftIO $ simpleBody p `shouldBe` ""
+
+      it "raises an error when attempting to delete multiple entities" $ do
+        request methodDelete "/items?id=gt.0&id=lt.6"
+            [("Prefer", "tx=commit"), singular]
+            ""
+          `shouldRespondWith`
+            [json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+            { matchStatus  = 406
+            , matchHeaders = [ matchContentTypeSingular
+                             , "Preference-Applied" <:> "tx=commit" ]
+            }
+
+        -- the rows should still exist
+        get "/items?id=gt.0&id=lt.6&order=id"
+          `shouldRespondWith`
+            [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |]
+            { matchStatus  = 200
+            , matchHeaders = ["Content-Range" <:> "0-4/*"]
+            }
+
+      it "raises an error when attempting to delete multiple entities with return=rep" $ do
+        request methodDelete "/items?id=gt.5&id=lt.11"
+            [("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular] ""
+          `shouldRespondWith`
+            [json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+            { matchStatus  = 406
+            , matchHeaders = [ matchContentTypeSingular
+                             , "Preference-Applied" <:> "tx=commit" ]
+            }
+
+        -- the rows should still exist
+        get "/items?id=gt.5&id=lt.11"
+          `shouldRespondWith` [json| [{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
+            { matchStatus  = 200
+            , matchHeaders = ["Content-Range" <:> "0-4/*"]
+            }
+
+      it "raises an error when deleting zero entities" $
+        request methodDelete "/items?id=lt.0"
+                [singular] ""
+          `shouldRespondWith`
+                [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+                { matchStatus  = 406
+                , matchHeaders = [matchContentTypeSingular]
+                }
+
+      it "raises an error when deleting zero entities with return=rep" $
+        request methodDelete "/items?id=lt.0"
+                [("Prefer", "return=representation"), singular] ""
+          `shouldRespondWith`
+                [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+                { matchStatus  = 406
+                , matchHeaders = [matchContentTypeSingular]
+                }
+
+    context "when calling a stored proc" $ do
+      it "fails for zero rows" $
+        request methodPost "/rpc/getproject"
+                [singular] [json|{ "id": 9999999}|]
+          `shouldRespondWith`
+                [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+                { matchStatus  = 406
+                , matchHeaders = [matchContentTypeSingular]
+                }
+
+      -- this one may be controversial, should vnd.pgrst.object include
+      -- the likes of 2 and "hello?"
+      it "succeeds for scalar result" $
+        request methodPost "/rpc/sayhello"
+          [singular] [json|{ "name": "world"}|]
+          `shouldRespondWith` 200
+
+      it "returns a single object for json proc" $
+        request methodPost "/rpc/getproject"
+            [singular] [json|{ "id": 1}|]
+          `shouldRespondWith`
+            [json|{"id":1,"name":"Windows 7","client_id":1}|]
+            { matchHeaders = [matchContentTypeSingular] }
+
+      it "fails for multiple rows" $
+        request methodPost "/rpc/getallprojects"
+                [singular] "{}"
+          `shouldRespondWith`
+                [json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+                { matchStatus  = 406
+                , matchHeaders = [matchContentTypeSingular]
+                }
+
+      it "fails for multiple rows with rolled back changes" $ do
+        post "/rpc/getproject?select=id,name"
+            [json| {"id": 1} |]
+          `shouldRespondWith`
+            [json|[{"id":1,"name":"Windows 7"}]|]
+
+        request methodPost "/rpc/setprojects"
+            [("Prefer", "tx=commit"), singular]
+            [json| {"id_l": 1, "id_h": 2, "name": "changed"} |]
+          `shouldRespondWith`
+            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
+            { matchStatus  = 406
+            , matchHeaders = [ matchContentTypeSingular
+                             , "Preference-Applied" <:> "tx=commit" ]
+            }
+
+        -- should rollback function
+        post "/rpc/getproject?select=id,name"
+            [json| {"id": 1} |]
+          `shouldRespondWith`
+            [json|[{"id":1,"name":"Windows 7"}]|]
diff --git a/test/spec/Feature/UnicodeSpec.hs b/test/spec/Feature/UnicodeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/UnicodeSpec.hs
@@ -0,0 +1,34 @@
+module Feature.UnicodeSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude hiding (get)
+
+spec :: SpecWith ((), Application)
+spec =
+  describe "Reading and writing to unicode schema and table names" $
+    it "Can read and write values" $ do
+      get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
+        `shouldRespondWith` "[]"
+
+      request methodPost "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
+          [("Prefer", "tx=commit"), ("Prefer", "return=representation")]
+          [json| { "هویت": 1 } |]
+        `shouldRespondWith`
+          [json| [{ "هویت": 1 }] |]
+          { matchStatus = 201 }
+
+      get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
+        `shouldRespondWith`
+          [json| [{ "هویت": 1 }] |]
+
+      request methodDelete "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
+          [("Prefer", "tx=commit")]
+          ""
+        `shouldRespondWith`
+          204
diff --git a/test/spec/Feature/UpdateSpec.hs b/test/spec/Feature/UpdateSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/UpdateSpec.hs
@@ -0,0 +1,349 @@
+module Feature.UpdateSpec where
+
+import Network.Wai (Application)
+import Test.Hspec  hiding (pendingWith)
+
+import Network.HTTP.Types
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import Protolude  hiding (get)
+import SpecHelper
+
+spec :: SpecWith ((), Application)
+spec = do
+  describe "Patching record" $ do
+    context "to unknown uri" $
+      it "indicates no table found by returning 404" $
+        request methodPatch "/fake" []
+          [json| { "real": false } |]
+            `shouldRespondWith` 404
+
+    context "on an empty table" $
+      it "indicates no records found to update by returning 404" $
+        request methodPatch "/empty_table" []
+          [json| { "extra":20 } |]
+          `shouldRespondWith` ""
+          { matchStatus  = 404,
+            matchHeaders = []
+          }
+
+    context "with invalid json payload" $
+      it "fails with 400 and error" $
+        request methodPatch "/simple_pk" [] "}{ x = 2"
+          `shouldRespondWith`
+          [json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
+          { matchStatus  = 400,
+            matchHeaders = [matchContentTypeJson]
+          }
+
+    context "with no payload" $
+      it "fails with 400 and error" $
+        request methodPatch "/items" [] ""
+          `shouldRespondWith`
+          [json|{"message":"Error in $: not enough input"}|]
+          { matchStatus  = 400,
+            matchHeaders = [matchContentTypeJson]
+          }
+
+    context "in a nonempty table" $ do
+      it "can update a single item" $ do
+        patch "/items?id=eq.2"
+            [json| { "id":42 } |]
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 204
+            , matchHeaders = ["Content-Range" <:> "0-0/*"]
+            }
+
+      it "returns empty array when no rows updated and return=rep" $
+        request methodPatch "/items?id=eq.999999"
+          [("Prefer", "return=representation")] [json| { "id":999999 } |]
+          `shouldRespondWith` "[]"
+          {
+            matchStatus  = 404,
+            matchHeaders = []
+          }
+
+      it "gives a 404 when no rows updated" $
+        request methodPatch "/items?id=eq.99999999" []
+          [json| { "id": 42 } |]
+            `shouldRespondWith` 404
+
+      it "returns updated object as array when return=rep" $
+        request methodPatch "/items?id=eq.2"
+          [("Prefer", "return=representation")] [json| { "id":2 } |]
+          `shouldRespondWith` [json|[{"id":2}]|]
+          { matchStatus  = 200,
+            matchHeaders = ["Content-Range" <:> "0-0/*"]
+          }
+
+      it "can update multiple items" $ do
+        get "/no_pk?select=a&b=eq.1"
+          `shouldRespondWith`
+            [json|[]|]
+
+        request methodPatch "/no_pk?b=eq.0"
+            [("Prefer", "tx=commit")]
+            [json| { b: "1" } |]
+          `shouldRespondWith`
+            ""
+            { matchStatus  = 204
+            , matchHeaders = ["Content-Range" <:> "0-1/*"
+                             , "Preference-Applied" <:> "tx=commit" ]
+            }
+
+        -- check it really got updated
+        get "/no_pk?select=a&b=eq.1"
+          `shouldRespondWith`
+            [json|[ { a: "1" }, { a: "2" } ]|]
+
+        -- put value back for other tests
+        request methodPatch "/no_pk?b=eq.1"
+            [("Prefer", "tx=commit")]
+            [json| { b: "0" } |]
+          `shouldRespondWith`
+            204
+
+      it "can set a column to NULL" $ do
+        request methodPatch "/no_pk?a=eq.1"
+            [("Prefer", "return=representation")]
+            [json| { b: null } |]
+          `shouldRespondWith`
+            [json| [{ a: "1", b: null }] |]
+
+      context "filtering by a computed column" $ do
+        it "is successful" $
+          request methodPatch
+            "/items?is_first=eq.true"
+            [("Prefer", "return=representation")]
+            [json| { id: 100 } |]
+            `shouldRespondWith` [json| [{ id: 100 }] |]
+            { matchStatus  = 200,
+              matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
+            }
+
+        it "indicates no records updated by returning 404" $
+          request methodPatch
+            "/items?always_true=eq.false"
+            [("Prefer", "return=representation")]
+            [json| { id: 100 } |]
+            `shouldRespondWith` "[]"
+            { matchStatus  = 404,
+              matchHeaders = []
+            }
+
+      context "with representation requested" $ do
+        it "can provide a representation" $ do
+          _ <- post "/items"
+            [json| { id: 1 } |]
+          request methodPatch
+            "/items?id=eq.1"
+            [("Prefer", "return=representation")]
+            [json| { id: 99 } |]
+            `shouldRespondWith` [json| [{id:99}] |]
+            { matchHeaders = [matchContentTypeJson] }
+          -- put value back for other tests
+          void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]
+
+        it "can return computed columns" $
+          request methodPatch
+            "/items?id=eq.1&select=id,always_true"
+            [("Prefer", "return=representation")]
+            [json| { id: 1 } |]
+            `shouldRespondWith` [json| [{ id: 1, always_true: true }] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+        it "can select overloaded computed columns" $ do
+          request methodPatch
+            "/items?id=eq.1&select=id,computed_overload"
+            [("Prefer", "return=representation")]
+            [json| { id: 1 } |]
+            `shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
+            { matchHeaders = [matchContentTypeJson] }
+          request methodPatch
+            "/items2?id=eq.1&select=id,computed_overload"
+            [("Prefer", "return=representation")]
+            [json| { id: 1 } |]
+            `shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
+            { matchHeaders = [matchContentTypeJson] }
+
+      it "ignores ?select= when return not set or return=minimal" $ do
+        request methodPatch "/items?id=eq.1&select=id" [] [json| { id:1 } |]
+          `shouldRespondWith` ""
+          {
+            matchStatus  = 204,
+            matchHeaders = ["Content-Range" <:> "0-0/*"]
+          }
+        request methodPatch "/items?id=eq.1&select=id" [("Prefer", "return=minimal")] [json| { id:1 } |]
+          `shouldRespondWith` ""
+          {
+            matchStatus  = 204,
+            matchHeaders = ["Content-Range" <:> "0-0/*"]
+          }
+
+      context "when patching with an empty body" $ do
+        it "makes no updates and returns 204 without return= and without ?select=" $ do
+          request methodPatch "/items" [] [json| {} |]
+            `shouldRespondWith` ""
+            {
+              matchStatus  = 204,
+              matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+          request methodPatch "/items" [] [json| [] |]
+            `shouldRespondWith` ""
+            {
+              matchStatus  = 204,
+              matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+          request methodPatch "/items" [] [json| [{}] |]
+            `shouldRespondWith` ""
+            {
+              matchStatus  = 204,
+              matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+        it "makes no updates and returns 204 without return= and with ?select=" $ do
+          request methodPatch "/items?select=id" [] [json| {} |]
+            `shouldRespondWith` ""
+            {
+              matchStatus  = 204,
+              matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+          request methodPatch "/items?select=id" [] [json| [] |]
+            `shouldRespondWith` ""
+            {
+              matchStatus  = 204,
+              matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+          request methodPatch "/items?select=id" [] [json| [{}] |]
+            `shouldRespondWith` ""
+            {
+              matchStatus  = 204,
+              matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+        it "makes no updates and returns 200 with return=rep and without ?select=" $
+          request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
+            `shouldRespondWith` "[]"
+            {
+              matchStatus  = 200,
+              matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+        it "makes no updates and returns 200 with return=rep and with ?select=" $
+          request methodPatch "/items?select=id" [("Prefer", "return=representation")] [json| {} |]
+            `shouldRespondWith` "[]"
+            {
+              matchStatus  = 200,
+              matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+        it "makes no updates and returns 200 with return=rep and with ?select= for overloaded computed columns" $
+          request methodPatch "/items?select=id,computed_overload" [("Prefer", "return=representation")] [json| {} |]
+            `shouldRespondWith` "[]"
+            {
+              matchStatus  = 200,
+              matchHeaders = ["Content-Range" <:> "*/*"]
+            }
+
+    context "with unicode values" $
+      it "succeeds and returns values intact" $ do
+        request methodPatch "/no_pk?a=eq.1"
+            [("Prefer", "return=representation")]
+            [json| { "a":"圍棋", "b":"￥" } |]
+          `shouldRespondWith`
+            [json|[ { "a":"圍棋", "b":"￥" } ]|]
+
+    context "PATCH with ?columns parameter" $ do
+      it "ignores json keys not included in ?columns" $ do
+        request methodPatch "/articles?id=eq.1&columns=body"
+            [("Prefer", "return=representation")]
+            [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |]
+          `shouldRespondWith`
+            [json|[{"id": 1, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
+
+      it "ignores json keys and gives 404 if no record updated" $
+        request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
+          [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404
+
+  context "tables with self reference foreign keys" $ do
+    it "embeds children after update" $
+      request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"
+              [("Prefer", "return=representation")]
+        [json|{"name": "tardis-patched"}|]
+        `shouldRespondWith`
+        [json|
+          [ { "id": 0, "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
+        |]
+        { matchStatus  = 200,
+          matchHeaders = [matchContentTypeJson]
+        }
+
+    it "embeds parent, children and grandchildren after update" $
+      request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name,web_content(name)),parent_content:p_web_id(name)"
+              [("Prefer", "return=representation")]
+        [json|{"name": "tardis-patched-2"}|]
+        `shouldRespondWith`
+        [json| [
+          {
+            "id": 0,
+            "name": "tardis-patched-2",
+            "parent_content": { "name": "wat" },
+            "web_content": [
+                { "name": "fezz", "web_content": [ { "name": "wut" } ] },
+                { "name": "foo",  "web_content": [] },
+                { "name": "bar",  "web_content": [] }
+            ]
+          }
+        ] |]
+        { matchStatus  = 200,
+          matchHeaders = [matchContentTypeJson]
+        }
+
+    it "embeds children after update without explicitly including the id in the ?select" $
+      request methodPatch "/web_content?id=eq.0&select=name,web_content(name)"
+              [("Prefer", "return=representation")]
+        [json|{"name": "tardis-patched"}|]
+        `shouldRespondWith`
+        [json|
+          [ { "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
+        |]
+        { matchStatus  = 200,
+          matchHeaders = [matchContentTypeJson]
+        }
+
+    it "embeds an M2M relationship plus parent after update" $
+      request methodPatch "/users?id=eq.1&select=name,tasks(name,project:projects(name))"
+              [("Prefer", "return=representation")]
+        [json|{"name": "Kevin Malone"}|]
+        `shouldRespondWith`
+        [json|[
+          {
+            "name": "Kevin Malone",
+            "tasks": [
+                { "name": "Design w7", "project": { "name": "Windows 7" } },
+                { "name": "Code w7", "project": { "name": "Windows 7" } },
+                { "name": "Design w10", "project": { "name": "Windows 10" } },
+                { "name": "Code w10", "project": { "name": "Windows 10" } }
+            ]
+          }
+        ]|]
+        { matchStatus  = 200,
+          matchHeaders = [matchContentTypeJson]
+        }
+
+  context "table with limited privileges" $ do
+    it "succeeds updating row and gives a 204 when using return=minimal" $
+      request methodPatch "/app_users?id=eq.1" [("Prefer", "return=minimal")]
+        [json| { "password": "passxyz" } |]
+          `shouldRespondWith` 204
+
+    it "can update without return=minimal and no explicit select" $
+      request methodPatch "/app_users?id=eq.1" []
+        [json| { "password": "passabc" } |]
+          `shouldRespondWith` 204
diff --git a/test/spec/Feature/UpsertSpec.hs b/test/spec/Feature/UpsertSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Feature/UpsertSpec.hs
@@ -0,0 +1,427 @@
+module Feature.UpsertSpec where
+
+import Network.Wai (Application)
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Test.Hspec.Wai.JSON
+
+import PostgREST.Config.PgVersion (PgVersion, pgVersion110)
+
+import Protolude  hiding (get, put)
+import SpecHelper
+
+spec :: PgVersion -> SpecWith ((), Application)
+spec actualPgVersion =
+  describe "UPSERT" $ do
+    context "with POST" $ do
+      context "when Prefer: resolution=merge-duplicates is specified" $ do
+        it "INSERTs and UPDATEs rows on pk conflict" $
+          request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
+            [json| [
+              { "name": "Javascript", "rank": 6 },
+              { "name": "Java", "rank": 2 },
+              { "name": "C", "rank": 1 }
+            ]|] `shouldRespondWith` [json| [
+              { "name": "Javascript", "rank": 6 },
+              { "name": "Java", "rank": 2 },
+              { "name": "C", "rank": 1 }
+            ]|]
+            { matchStatus = 201
+            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
+            }
+
+        it "INSERTs and UPDATEs row on composite pk conflict" $
+          request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
+            [json| [
+              { "first_name": "Frances M.", "last_name": "Roe", "salary": "30000" },
+              { "first_name": "Peter S.", "last_name": "Yang", "salary": 42000 }
+            ]|] `shouldRespondWith` [json| [
+              { "first_name": "Frances M.", "last_name": "Roe", "salary": "$30,000.00", "company": "One-Up Realty", "occupation": "Author" },
+              { "first_name": "Peter S.", "last_name": "Yang", "salary": "$42,000.00", "company": null, "occupation": null }
+            ]|]
+            { matchStatus = 201
+            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
+            }
+
+        when (actualPgVersion >= pgVersion110) $
+          it "INSERTs and UPDATEs rows on composite pk conflict for partitioned tables" $
+            request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
+              [json| [
+                { "name": "Murcielago", "year": 2001, "car_brand_name": null},
+                { "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }
+              ]|] `shouldRespondWith` [json| [
+                { "name": "Murcielago", "year": 2001, "car_brand_name": null},
+                { "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }
+              ]|]
+              { matchStatus = 201
+              , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
+              }
+
+        it "succeeds when the payload has no elements" $
+          request methodPost "/articles" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
+            [json|[]|] `shouldRespondWith`
+            [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
+
+        it "INSERTs and UPDATEs rows on single unique key conflict" $
+          request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
+            [json| [
+              { "unique_key": 1, "value": "B" },
+              { "unique_key": 2, "value": "C" }
+            ]|] `shouldRespondWith` [json| [
+              { "unique_key": 1, "value": "B" },
+              { "unique_key": 2, "value": "C" }
+            ]|]
+            { matchStatus = 201
+            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
+            }
+
+        it "INSERTs and UPDATEs rows on compound unique keys conflict" $
+          request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
+            [json| [
+              { "key1": 1, "key2": 1, "value": "B" },
+              { "key1": 1, "key2": 2, "value": "C" }
+            ]|] `shouldRespondWith` [json| [
+              { "key1": 1, "key2": 1, "value": "B" },
+              { "key1": 1, "key2": 2, "value": "C" }
+            ]|]
+            { matchStatus = 201
+            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
+            }
+
+      context "when Prefer: resolution=ignore-duplicates is specified" $ do
+        it "INSERTs and ignores rows on pk conflict" $
+          request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
+            [json|[
+              { "name": "PHP", "rank": 9 },
+              { "name": "Python", "rank": 10 }
+            ]|] `shouldRespondWith` [json|[
+              { "name": "PHP", "rank": 9 }
+            ]|]
+            { matchStatus = 201
+            , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
+            }
+
+        it "INSERTs and ignores rows on composite pk conflict" $
+          request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
+            [json|[
+              { "first_name": "Daniel B.", "last_name": "Lyon", "salary": "72000", "company": null, "occupation": null },
+              { "first_name": "Sara M.", "last_name": "Torpey", "salary": 60000, "company": "Burstein-Applebee", "occupation": "Soil scientist" }
+            ]|] `shouldRespondWith` [json|[
+              { "first_name": "Sara M.", "last_name": "Torpey", "salary": "$60,000.00", "company": "Burstein-Applebee", "occupation": "Soil scientist" }
+            ]|]
+            { matchStatus = 201
+            , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
+            }
+
+        when (actualPgVersion >= pgVersion110) $
+          it "INSERTs and ignores rows on composite pk conflict for partitioned tables" $
+            request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
+              [json| [
+                { "name": "Murcielago", "year": 2001, "car_brand_name": "Ferrari" },
+                { "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }
+              ]|] `shouldRespondWith` [json| [
+                { "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }
+              ]|]
+              { matchStatus = 201
+              , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
+              }
+
+        it "INSERTs and ignores rows on single unique key conflict" $
+          request methodPost "/single_unique?on_conflict=unique_key"
+              [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
+              [json| [
+                { "unique_key": 1, "value": "B" },
+                { "unique_key": 2, "value": "C" },
+                { "unique_key": 3, "value": "D" }
+              ]|]
+            `shouldRespondWith`
+              [json| [
+                { "unique_key": 2, "value": "C" },
+                { "unique_key": 3, "value": "D" }
+              ]|]
+              { matchStatus = 201
+              , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
+              }
+
+        it "INSERTs and UPDATEs rows on compound unique keys conflict" $
+          request methodPost "/compound_unique?on_conflict=key1,key2"
+              [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
+              [json| [
+                { "key1": 1, "key2": 1, "value": "B" },
+                { "key1": 1, "key2": 2, "value": "C" },
+                { "key1": 1, "key2": 3, "value": "D" }
+              ]|]
+            `shouldRespondWith`
+              [json| [
+                { "key1": 1, "key2": 2, "value": "C" },
+                { "key1": 1, "key2": 3, "value": "D" }
+              ]|]
+              { matchStatus = 201
+              , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
+              }
+
+      it "succeeds if the table has only PK cols and no other cols" $ do
+        request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
+          [json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]
+          `shouldRespondWith`
+          [json|[ { "id": 3} ]|]
+          { matchStatus = 201 ,
+            matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates",
+            matchContentTypeJson] }
+
+        request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
+          [json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
+          `shouldRespondWith`
+          [json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
+          { matchStatus = 201 ,
+            matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates",
+            matchContentTypeJson] }
+
+      it "succeeds and ignores the Prefer: resolution header(no Preference-Applied present) if the table has no PK" $
+        request methodPost "/no_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
+          [json|[ { "a": "1", "b": "0" } ]|]
+          `shouldRespondWith`
+          [json|[ { "a": "1", "b": "0" } ]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
+
+      it "succeeds if not a single resource is created" $ do
+        request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
+          [json|[ { "name": "Java", "rank": 1 } ]|] `shouldRespondWith`
+          [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
+        request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
+          [json|[ { "name": "Java", "rank": 1 }, { "name": "C", "rank": 2 } ]|] `shouldRespondWith`
+          [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
+
+    context "with PUT" $ do
+      context "Restrictions" $ do
+        it "fails if Range is specified" $
+          request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]
+            [json| [ { "name": "Javascript", "rank": 1 } ]|]
+            `shouldRespondWith`
+            [json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
+            { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
+
+        it "fails if limit is specified" $
+          put "/tiobe_pls?name=eq.Javascript&limit=1"
+            [json| [ { "name": "Javascript", "rank": 1 } ]|]
+            `shouldRespondWith`
+            [json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
+            { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
+
+        it "fails if offset is specified" $
+          put "/tiobe_pls?name=eq.Javascript&offset=1"
+            [json| [ { "name": "Javascript", "rank": 1 } ]|]
+            `shouldRespondWith`
+            [json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
+            { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
+
+        it "rejects every other filter than pk cols eq's" $ do
+          put "/tiobe_pls?rank=eq.19"
+            [json| [ { "name": "Go", "rank": 19 } ]|]
+            `shouldRespondWith`
+            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
+            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
+
+          put "/tiobe_pls?id=not.eq.Java"
+            [json| [ { "name": "Go", "rank": 19 } ]|]
+            `shouldRespondWith`
+            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
+            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
+          put "/tiobe_pls?id=in.(Go)"
+            [json| [ { "name": "Go", "rank": 19 } ]|]
+            `shouldRespondWith`
+            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
+            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
+          put "/tiobe_pls?and=(id.eq.Go)"
+            [json| [ { "name": "Go", "rank": 19 } ]|]
+            `shouldRespondWith`
+            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
+            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
+
+        it "fails if not all composite key cols are specified as eq filters" $ do
+          put "/employees?first_name=eq.Susan"
+            [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
+            `shouldRespondWith`
+            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
+            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
+          put "/employees?last_name=eq.Heidt"
+            [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
+            `shouldRespondWith`
+            [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
+            { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
+
+      it "fails if the uri primary key doesn't match the payload primary key" $ do
+        put "/tiobe_pls?name=eq.MATLAB" [json| [ { "name": "Perl", "rank": 17 } ]|]
+          `shouldRespondWith`
+          [json|{"message":"Payload values do not match URL in primary key column(s)"}|]
+          { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
+        put "/employees?first_name=eq.Wendy&last_name=eq.Anderson"
+          [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
+          `shouldRespondWith`
+          [json|{"message":"Payload values do not match URL in primary key column(s)"}|]
+          { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
+
+      it "fails if the table has no PK" $
+        put "/no_pk?a=eq.one&b=eq.two" [json| [ { "a": "one", "b": "two" } ]|]
+          `shouldRespondWith`
+          [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
+          { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
+
+      context "Inserting row" $ do
+        it "succeeds on table with single pk col" $ do
+          -- assert that the next request will indeed be an insert
+          get "/tiobe_pls?name=eq.Go"
+            `shouldRespondWith`
+              [json|[]|]
+
+          request methodPut "/tiobe_pls?name=eq.Go"
+              [("Prefer", "return=representation")]
+              [json| [ { "name": "Go", "rank": 19 } ]|]
+            `shouldRespondWith`
+              [json| [ { "name": "Go", "rank": 19 } ]|]
+
+        it "succeeds on table with composite pk" $ do
+          -- assert that the next request will indeed be an insert
+          get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
+            `shouldRespondWith`
+              [json|[]|]
+
+          request methodPut "/employees?first_name=eq.Susan&last_name=eq.Heidt"
+              [("Prefer", "return=representation")]
+              [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
+            `shouldRespondWith`
+              [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
+
+        when (actualPgVersion >= pgVersion110) $
+          it "succeeds on a partitioned table with composite pk" $ do
+            -- assert that the next request will indeed be an insert
+            get "/car_models?name=eq.Supra&year=eq.2021"
+              `shouldRespondWith`
+                [json|[]|]
+
+            request methodPut "/car_models?name=eq.Supra&year=eq.2021"
+                [("Prefer", "return=representation")]
+                [json| [ { "name": "Supra", "year": 2021 } ]|]
+              `shouldRespondWith`
+                [json| [ { "name": "Supra", "year": 2021, "car_brand_name": null } ]|]
+
+        it "succeeds if the table has only PK cols and no other cols" $ do
+          -- assert that the next request will indeed be an insert
+          get "/only_pk?id=eq.10"
+            `shouldRespondWith`
+              [json|[]|]
+
+          request methodPut "/only_pk?id=eq.10"
+              [("Prefer", "return=representation")]
+              [json|[ { "id": 10 } ]|]
+            `shouldRespondWith`
+              [json|[ { "id": 10 } ]|]
+
+      context "Updating row" $ do
+        it "succeeds on table with single pk col" $ do
+          -- assert that the next request will indeed be an update
+          get "/tiobe_pls?name=eq.Java"
+            `shouldRespondWith`
+              [json|[ { "name": "Java", "rank": 1 } ]|]
+
+          request methodPut "/tiobe_pls?name=eq.Java"
+              [("Prefer", "return=representation")]
+              [json| [ { "name": "Java", "rank": 13 } ]|]
+            `shouldRespondWith`
+              [json| [ { "name": "Java", "rank": 13 } ]|]
+
+        -- TODO: move this to SingularSpec?
+        it "succeeds if the payload has more than one row, but it only puts the first element" $ do
+          -- assert that the next request will indeed be an update
+          get "/tiobe_pls?name=eq.Java"
+            `shouldRespondWith`
+              [json|[ { "name": "Java", "rank": 1 } ]|]
+
+          request methodPut "/tiobe_pls?name=eq.Java"
+              [("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
+              [json| [ { "name": "Java", "rank": 19 }, { "name": "Swift", "rank": 12 } ] |]
+            `shouldRespondWith`
+              [json|{ "name": "Java", "rank": 19 }|]
+              { matchHeaders = [matchContentTypeSingular] }
+
+        it "succeeds on table with composite pk" $ do
+          -- assert that the next request will indeed be an update
+          get "/employees?first_name=eq.Frances M.&last_name=eq.Roe"
+            `shouldRespondWith`
+              [json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$24,000.00", "company": "One-Up Realty", "occupation": "Author" } ]|]
+
+          request methodPut "/employees?first_name=eq.Frances M.&last_name=eq.Roe"
+              [("Prefer", "return=representation")]
+              [json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "60000", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
+            `shouldRespondWith`
+              [json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
+
+        when (actualPgVersion >= pgVersion110) $
+          it "succeeds on a partitioned table with composite pk" $ do
+            -- assert that the next request will indeed be an update
+            get "/car_models?name=eq.DeLorean&year=eq.1981"
+              `shouldRespondWith`
+                [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": "DMC" } ]|]
+
+            request methodPut "/car_models?name=eq.DeLorean&year=eq.1981"
+                [("Prefer", "return=representation")]
+                [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]
+              `shouldRespondWith`
+                [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]
+
+        it "succeeds if the table has only PK cols and no other cols" $ do
+          -- assert that the next request will indeed be an update
+          get "/only_pk?id=eq.1"
+            `shouldRespondWith`
+              [json|[ { "id": 1 } ]|]
+
+          request methodPut "/only_pk?id=eq.1"
+              [("Prefer", "return=representation")]
+              [json|[ { "id": 1 } ]|]
+            `shouldRespondWith`
+              [json|[ { "id": 1 } ]|]
+
+      -- TODO: move this to SingularSpec?
+      it "works with return=representation and vnd.pgrst.object+json" $
+        request methodPut "/tiobe_pls?name=eq.Ruby"
+          [("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
+          [json| [ { "name": "Ruby", "rank": 11 } ]|]
+          `shouldRespondWith` [json|{ "name": "Ruby", "rank": 11 }|] { matchHeaders = [matchContentTypeSingular] }
+
+    context "with a camel case pk column" $ do
+      it "works with POST and merge-duplicates" $ do
+        request methodPost "/UnitTest"
+            [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
+            [json|[
+              { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
+              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
+            ]|]
+          `shouldRespondWith`
+            [json|[
+              { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
+              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
+            ]|]
+            { matchStatus = 201
+            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates"]
+            }
+
+      it "works with POST and ignore-duplicates headers" $ do
+        request methodPost "/UnitTest"
+            [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
+            [json|[
+              { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
+              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
+            ]|]
+          `shouldRespondWith`
+            [json|[
+              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
+            ]|]
+            { matchStatus = 201
+            , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
+            }
+
+      it "works with PUT" $ do
+        put "/UnitTest?idUnitTest=eq.1" [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] `shouldRespondWith` 204
+        get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`
+          [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|]
diff --git a/test/spec/Main.hs b/test/spec/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/Main.hs
@@ -0,0 +1,234 @@
+module Main where
+
+import qualified Data.Aeson                 as JSON
+import qualified Hasql.Pool                 as P
+import qualified Hasql.Transaction.Sessions as HT
+
+import Data.Function      (id)
+import Data.List.NonEmpty (toList)
+
+import Test.Hspec
+
+import PostgREST.App             (postgrest)
+import PostgREST.Config          (AppConfig (..), LogLevel (..))
+import PostgREST.Config.Database (queryPgVersion)
+import PostgREST.DbStructure     (queryDbStructure)
+import Protolude                 hiding (toList, toS)
+import Protolude.Conv            (toS)
+import SpecHelper
+
+import qualified PostgREST.AppState as AppState
+
+import qualified Feature.AndOrParamsSpec
+import qualified Feature.AsymmetricJwtSpec
+import qualified Feature.AudienceJwtSecretSpec
+import qualified Feature.AuthSpec
+import qualified Feature.BinaryJwtSecretSpec
+import qualified Feature.ConcurrentSpec
+import qualified Feature.CorsSpec
+import qualified Feature.DeleteSpec
+import qualified Feature.DisabledOpenApiSpec
+import qualified Feature.EmbedDisambiguationSpec
+import qualified Feature.EmbedInnerJoinSpec
+import qualified Feature.ExtraSearchPathSpec
+import qualified Feature.HtmlRawOutputSpec
+import qualified Feature.IgnorePrivOpenApiSpec
+import qualified Feature.InsertSpec
+import qualified Feature.JsonOperatorSpec
+import qualified Feature.LegacyGucsSpec
+import qualified Feature.MultipleSchemaSpec
+import qualified Feature.NoJwtSpec
+import qualified Feature.NonexistentSchemaSpec
+import qualified Feature.OpenApiSpec
+import qualified Feature.OptionsSpec
+import qualified Feature.ProxySpec
+import qualified Feature.QueryLimitedSpec
+import qualified Feature.QuerySpec
+import qualified Feature.RangeSpec
+import qualified Feature.RawOutputTypesSpec
+import qualified Feature.RollbackSpec
+import qualified Feature.RootSpec
+import qualified Feature.RpcPreRequestGucsSpec
+import qualified Feature.RpcSpec
+import qualified Feature.SingularSpec
+import qualified Feature.UnicodeSpec
+import qualified Feature.UpdateSpec
+import qualified Feature.UpsertSpec
+
+
+main :: IO ()
+main = do
+  testDbConn <- getEnvVarWithDefault "PGRST_DB_URI" "postgres://postgrest_test@localhost/postgrest_test"
+
+  pool <- P.acquire (3, 10, toS testDbConn)
+
+  actualPgVersion <- either (panic.show) id <$> P.use pool queryPgVersion
+
+  baseDbStructure <-
+    loadDbStructure pool
+      (configDbSchemas $ testCfg testDbConn)
+      (configDbExtraSearchPath $ testCfg testDbConn)
+
+  let
+    -- For tests that run with the same refDbStructure
+    app cfg = do
+      let config = cfg testDbConn
+      appState <- AppState.initWithPool pool config
+      AppState.putPgVersion appState actualPgVersion
+      AppState.putDbStructure appState baseDbStructure
+      when (isJust $ configDbRootSpec config) $
+        AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure
+      return ((), postgrest LogCrit appState $ pure ())
+
+    -- For tests that run with a different DbStructure(depends on configSchemas)
+    appDbs cfg = do
+      let config = cfg testDbConn
+      customDbStructure <-
+        loadDbStructure pool
+          (configDbSchemas config)
+          (configDbExtraSearchPath config)
+      appState <- AppState.initWithPool pool config
+      AppState.putPgVersion appState actualPgVersion
+      AppState.putDbStructure appState customDbStructure
+      when (isJust $ configDbRootSpec config) $
+        AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure
+      return ((), postgrest LogCrit appState $ pure ())
+
+  let withApp              = app testCfg
+      maxRowsApp           = app testMaxRowsCfg
+      disabledOpenApi      = app testDisabledOpenApiCfg
+      proxyApp             = app testProxyCfg
+      noJwtApp             = app testCfgNoJWT
+      binaryJwtApp         = app testCfgBinaryJWT
+      audJwtApp            = app testCfgAudienceJWT
+      asymJwkApp           = app testCfgAsymJWK
+      asymJwkSetApp        = app testCfgAsymJWKSet
+      rootSpecApp          = app testCfgRootSpec
+      htmlRawOutputApp     = app testCfgHtmlRawOutput
+      responseHeadersApp   = app testCfgResponseHeaders
+      disallowRollbackApp  = app testCfgDisallowRollback
+      forceRollbackApp     = app testCfgForceRollback
+      testCfgLegacyGucsApp = app testCfgLegacyGucs
+
+      extraSearchPathApp   = appDbs testCfgExtraSearchPath
+      unicodeApp           = appDbs testUnicodeCfg
+      nonexistentSchemaApp = appDbs testNonexistentSchemaCfg
+      multipleSchemaApp    = appDbs testMultipleSchemaCfg
+      ignorePrivOpenApi    = appDbs testIgnorePrivOpenApiCfg
+
+  let analyze :: IO ()
+      analyze = do
+        analyzeTable testDbConn "items"
+        analyzeTable testDbConn "child_entities"
+
+      specs = uncurry describe <$> [
+          ("Feature.AndOrParamsSpec"         , Feature.AndOrParamsSpec.spec actualPgVersion)
+        , ("Feature.AuthSpec"                , Feature.AuthSpec.spec actualPgVersion)
+        , ("Feature.ConcurrentSpec"          , Feature.ConcurrentSpec.spec)
+        , ("Feature.CorsSpec"                , Feature.CorsSpec.spec)
+        , ("Feature.DeleteSpec"              , Feature.DeleteSpec.spec)
+        , ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)
+        , ("Feature.EmbedInnerJoinSpec"      , Feature.EmbedInnerJoinSpec.spec)
+        , ("Feature.InsertSpec"              , Feature.InsertSpec.spec actualPgVersion)
+        , ("Feature.JsonOperatorSpec"        , Feature.JsonOperatorSpec.spec actualPgVersion)
+        , ("Feature.OpenApiSpec"             , Feature.OpenApiSpec.spec actualPgVersion)
+        , ("Feature.OptionsSpec"             , Feature.OptionsSpec.spec actualPgVersion)
+        , ("Feature.QuerySpec"               , Feature.QuerySpec.spec actualPgVersion)
+        , ("Feature.RawOutputTypesSpec"      , Feature.RawOutputTypesSpec.spec)
+        , ("Feature.RpcSpec"                 , Feature.RpcSpec.spec actualPgVersion)
+        , ("Feature.SingularSpec"            , Feature.SingularSpec.spec)
+        , ("Feature.UpdateSpec"              , Feature.UpdateSpec.spec)
+        , ("Feature.UpsertSpec"              , Feature.UpsertSpec.spec actualPgVersion)
+        ]
+
+  hspec $ do
+    mapM_ (parallel . before withApp) specs
+
+    -- we analyze to get accurate results from EXPLAIN
+    parallel $ beforeAll_ analyze . before withApp $
+      describe "Feature.RangeSpec" Feature.RangeSpec.spec
+
+    -- this test runs with a raw-output-media-types set to text/html
+    parallel $ before htmlRawOutputApp $
+      describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec
+
+    -- this test runs with a different server flag
+    parallel $ before maxRowsApp $
+      describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec
+
+    -- this test runs with a different schema
+    parallel $ before unicodeApp $
+      describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec
+
+    -- this test runs with openapi-mode set to disabled
+    parallel $ before disabledOpenApi $
+      describe "Feature.DisabledOpenApiSpec" Feature.DisabledOpenApiSpec.spec
+
+    -- this test runs with openapi-mode set to ignore-acl
+    parallel $ before ignorePrivOpenApi $
+      describe "Feature.IgnorePrivOpenApiSpec" Feature.IgnorePrivOpenApiSpec.spec
+
+    -- this test runs with a proxy
+    parallel $ before proxyApp $
+      describe "Feature.ProxySpec" Feature.ProxySpec.spec
+
+    -- this test runs without a JWT secret
+    parallel $ before noJwtApp $
+      describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec
+
+    -- this test runs with a binary JWT secret
+    parallel $ before binaryJwtApp $
+      describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec
+
+    -- this test runs with a binary JWT secret and an audience claim
+    parallel $ before audJwtApp $
+      describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec
+
+    -- this test runs with asymmetric JWK
+    parallel $ before asymJwkApp $
+      describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
+
+    -- this test runs with asymmetric JWKSet
+    parallel $ before asymJwkSetApp $
+      describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
+
+    -- this test runs with a nonexistent db-schema
+    parallel $ before nonexistentSchemaApp $
+      describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
+
+    -- this test runs with an extra search path
+    parallel $ before extraSearchPathApp $
+      describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec
+
+    -- this test runs with a root spec function override
+    parallel $ before rootSpecApp $
+      describe "Feature.RootSpec" Feature.RootSpec.spec
+    parallel $ before responseHeadersApp $
+      describe "Feature.RpcPreRequestGucsSpec" Feature.RpcPreRequestGucsSpec.spec
+
+    -- this test runs with multiple schemas
+    parallel $ before multipleSchemaApp $
+      describe "Feature.MultipleSchemaSpec" Feature.MultipleSchemaSpec.spec
+
+    -- this test runs with db-uses-legacy-gucs = false
+    parallel $ before testCfgLegacyGucsApp $
+      describe "Feature.LegacyGucsSpec" Feature.LegacyGucsSpec.spec
+
+    -- Note: the rollback tests can not run in parallel, because they test persistance and
+    -- this results in race conditions
+
+    -- this test runs with tx-rollback-all = true and tx-allow-override = true
+    before withApp $
+      describe"Feature.RollbackAllowedSpec" Feature.RollbackSpec.allowed
+
+    -- this test runs with tx-rollback-all = false and tx-allow-override = false
+    before disallowRollbackApp $
+      describe "Feature.RollbackDisallowedSpec" Feature.RollbackSpec.disallowed
+
+    -- this test runs with tx-rollback-all = true and tx-allow-override = false
+    before forceRollbackApp $
+      describe "Feature.RollbackForcedSpec" Feature.RollbackSpec.forced
+
+  where
+    loadDbStructure pool schemas extraSearchPath =
+      either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ queryDbStructure (toList schemas) extraSearchPath True)
diff --git a/test/spec/QueryCost.hs b/test/spec/QueryCost.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/QueryCost.hs
@@ -0,0 +1,89 @@
+module Main where
+
+import           Control.Lens                      ((^?))
+import qualified Data.Aeson.Lens                   as L
+import qualified Hasql.Decoders                    as HD
+import qualified Hasql.DynamicStatements.Snippet   as H
+import qualified Hasql.DynamicStatements.Statement as H
+import qualified Hasql.Pool                        as P
+import qualified Hasql.Statement                   as H
+import qualified Hasql.Transaction                 as HT
+import qualified Hasql.Transaction.Sessions        as HT
+import           Text.Heredoc
+
+import Protolude      hiding (get, toS)
+import Protolude.Conv (toS)
+
+import PostgREST.Query.QueryBuilder (requestToCallProcQuery)
+import PostgREST.Request.Types
+
+import PostgREST.DbStructure.Identifiers
+import PostgREST.DbStructure.Proc
+
+import SpecHelper (getEnvVarWithDefault)
+
+import Test.Hspec
+
+main :: IO ()
+main = do
+  testDbConn <- getEnvVarWithDefault "PGRST_DB_URI" "postgres://postgrest_test@localhost/postgrest_test"
+  pool <- P.acquire (3, 10, toS testDbConn)
+
+  hspec $ describe "QueryCost" $
+    context "call proc query" $ do
+      it "should not exceed cost when calling setof composite proc" $ do
+        cost <- exec pool $
+          requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
+          (KeyParams [ProcParam "id" "int" True False])
+          (Just [str| {"id": 3} |]) False False [])
+        liftIO $
+          cost `shouldSatisfy` (< Just 40)
+
+      it "should not exceed cost when calling setof composite proc with empty params" $ do
+        cost <- exec pool $
+          requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") (KeyParams []) Nothing False False [])
+        liftIO $
+          cost `shouldSatisfy` (< Just 30)
+
+      it "should not exceed cost when calling scalar proc" $ do
+        cost <- exec pool $
+          requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
+          (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
+          (Just [str| {"a": 3, "b": 4} |]) True False [])
+        liftIO $
+          cost `shouldSatisfy` (< Just 10)
+
+      context "params=multiple-objects" $ do
+        it "should not exceed cost when calling setof composite proc" $ do
+          cost <- exec pool $
+            requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")
+            (KeyParams [ProcParam "id" "int" True False])
+            (Just [str| [{"id": 1}, {"id": 4}] |]) False True [])
+          liftIO $ do
+            -- lower bound needed for now to make sure that cost is not Nothing
+            cost `shouldSatisfy` (> Just 2000)
+            cost `shouldSatisfy` (< Just 2100)
+
+        it "should not exceed cost when calling scalar proc" $ do
+          cost <- exec pool $
+            requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")
+            (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])
+            (Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False [])
+          liftIO $
+            cost `shouldSatisfy` (< Just 10)
+
+
+exec :: P.Pool -> H.Snippet -> IO (Maybe Int64)
+exec pool query =
+  join . rightToMaybe <$>
+  P.use pool (HT.transaction HT.ReadCommitted HT.Read $ HT.statement mempty $ explainCost query)
+
+explainCost :: H.Snippet -> H.Statement () (Maybe Int64)
+explainCost query =
+  H.dynamicallyParameterized snippet decodeExplain False
+ where
+   snippet = "EXPLAIN (FORMAT JSON) " <> query
+   decodeExplain :: HD.Result (Maybe Int64)
+   decodeExplain =
+     let row = HD.singleRow $ HD.column $ HD.nonNullable HD.bytea in
+     (^? L.nth 0 . L.key "Plan" .  L.key "Total Cost" . L._Integral) <$> row
diff --git a/test/spec/SpecHelper.hs b/test/spec/SpecHelper.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/SpecHelper.hs
@@ -0,0 +1,235 @@
+module SpecHelper where
+
+import qualified Data.ByteString.Base64 as B64 (decodeLenient)
+import qualified Data.ByteString.Char8  as BS
+import qualified Data.ByteString.Lazy   as BL
+import qualified Data.Map.Strict        as M
+import qualified Data.Set               as S
+import qualified System.IO.Error        as E
+
+import Data.Aeson           (Value (..), decode, encode)
+import Data.CaseInsensitive (CI (..), original)
+import Data.List            (lookup)
+import Data.List.NonEmpty   (fromList)
+import Network.Wai.Test     (SResponse (simpleBody, simpleHeaders, simpleStatus))
+import System.Environment   (getEnv)
+import System.Process       (readProcess)
+import Text.Regex.TDFA      ((=~))
+
+
+import Network.HTTP.Types
+import Test.Hspec
+import Test.Hspec.Wai
+import Text.Heredoc
+
+import PostgREST.Config                  (AppConfig (..),
+                                          JSPathExp (..),
+                                          LogLevel (..),
+                                          OpenAPIMode (..),
+                                          parseSecret)
+import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
+import Protolude                         hiding (toS)
+import Protolude.Conv                    (toS)
+
+matchContentTypeJson :: MatchHeader
+matchContentTypeJson = "Content-Type" <:> "application/json; charset=utf-8"
+
+matchContentTypeSingular :: MatchHeader
+matchContentTypeSingular = "Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"
+
+matchHeaderAbsent :: HeaderName -> MatchHeader
+matchHeaderAbsent name = MatchHeader $ \headers _body ->
+  case lookup name headers of
+    Just _  -> Just $ "unexpected header: " <> toS (original name) <> "\n"
+    Nothing -> Nothing
+
+validateOpenApiResponse :: [Header] -> WaiSession () ()
+validateOpenApiResponse headers = do
+  r <- request methodGet "/" headers ""
+  liftIO $
+    let respStatus = simpleStatus r in
+    respStatus `shouldSatisfy`
+      \s -> s == Status { statusCode = 200, statusMessage="OK" }
+  liftIO $
+    let respHeaders = simpleHeaders r in
+    respHeaders `shouldSatisfy`
+      \hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs
+  let Just body = decode (simpleBody r)
+  Just schema <- liftIO $ decode <$> BL.readFile "test/spec/fixtures/openapi.json"
+  let args :: M.Map Text Value
+      args = M.fromList
+        [ ( "schema", schema )
+        , ( "data", body ) ]
+      hdrs = acceptHdrs "application/json"
+  request methodPost "/rpc/validate_json_schema" hdrs (encode args)
+      `shouldRespondWith` "true"
+      { matchStatus = 200
+      , matchHeaders = []
+      }
+
+
+getEnvVarWithDefault :: Text -> Text -> IO Text
+getEnvVarWithDefault var def = toS <$>
+  getEnv (toS var) `E.catchIOError` const (return $ toS def)
+
+_baseCfg :: AppConfig
+_baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in
+  AppConfig {
+    configAppSettings           = [ ("app.settings.app_host", "localhost") , ("app.settings.external_api_secret", "0123456789abcdef") ]
+  , configDbAnonRole            = "postgrest_test_anonymous"
+  , configDbChannel             = mempty
+  , configDbChannelEnabled      = True
+  , configDbExtraSearchPath     = []
+  , configDbMaxRows             = Nothing
+  , configDbPoolSize            = 10
+  , configDbPoolTimeout         = 10
+  , configDbPreRequest          = Just $ QualifiedIdentifier "test" "switch_role"
+  , configDbPreparedStatements  = True
+  , configDbRootSpec            = Nothing
+  , configDbSchemas             = fromList ["test"]
+  , configDbConfig              = False
+  , configDbUri                 = mempty
+  , configDbUseLegacyGucs       = True
+  , configFilePath              = Nothing
+  , configJWKS                  = parseSecret <$> secret
+  , configJwtAudience           = Nothing
+  , configJwtRoleClaimKey       = [JSPKey "role"]
+  , configJwtSecret             = secret
+  , configJwtSecretIsBase64     = False
+  , configLogLevel              = LogCrit
+  , configOpenApiMode           = OAFollowPriv
+  , configOpenApiServerProxyUri = Nothing
+  , configRawMediaTypes         = []
+  , configServerHost            = "localhost"
+  , configServerPort            = 3000
+  , configServerUnixSocket      = Nothing
+  , configServerUnixSocketMode  = 432
+  , configDbTxAllowOverride     = True
+  , configDbTxRollbackAll       = True
+  }
+
+testCfg :: Text -> AppConfig
+testCfg testDbConn = _baseCfg { configDbUri = testDbConn }
+
+testCfgDisallowRollback :: Text -> AppConfig
+testCfgDisallowRollback testDbConn = (testCfg testDbConn) { configDbTxAllowOverride = False, configDbTxRollbackAll = False }
+
+testCfgForceRollback :: Text -> AppConfig
+testCfgForceRollback testDbConn = (testCfg testDbConn) { configDbTxAllowOverride = False, configDbTxRollbackAll = True }
+
+testCfgNoJWT :: Text -> AppConfig
+testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing, configJWKS = Nothing }
+
+testUnicodeCfg :: Text -> AppConfig
+testUnicodeCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["تست"] }
+
+testMaxRowsCfg :: Text -> AppConfig
+testMaxRowsCfg testDbConn = (testCfg testDbConn) { configDbMaxRows = Just 2 }
+
+testDisabledOpenApiCfg :: Text -> AppConfig
+testDisabledOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OADisabled }
+
+testIgnorePrivOpenApiCfg :: Text -> AppConfig
+testIgnorePrivOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OAIgnorePriv, configDbSchemas = fromList ["test", "v1"] }
+
+testProxyCfg :: Text -> AppConfig
+testProxyCfg testDbConn = (testCfg testDbConn) { configOpenApiServerProxyUri = Just "https://postgrest.com/openapi.json" }
+
+testCfgBinaryJWT :: Text -> AppConfig
+testCfgBinaryJWT testDbConn =
+  let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in
+  (testCfg testDbConn) {
+    configJwtSecret = secret
+  , configJWKS = parseSecret <$> secret
+  }
+
+testCfgAudienceJWT :: Text -> AppConfig
+testCfgAudienceJWT testDbConn =
+  let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in
+  (testCfg testDbConn) {
+    configJwtSecret = secret
+  , configJwtAudience = Just "youraudience"
+  , configJWKS = parseSecret <$> secret
+  }
+
+testCfgAsymJWK :: Text -> AppConfig
+testCfgAsymJWK testDbConn =
+  let secret = Just $ encodeUtf8 [str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|]
+  in (testCfg testDbConn) {
+    configJwtSecret = secret
+  , configJWKS = parseSecret <$> secret
+  }
+
+testCfgAsymJWKSet :: Text -> AppConfig
+testCfgAsymJWKSet testDbConn =
+  let secret = Just $ encodeUtf8 [str|{"keys": [{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}]}|]
+  in (testCfg testDbConn) {
+    configJwtSecret = secret
+  , configJWKS = parseSecret <$> secret
+  }
+
+testNonexistentSchemaCfg :: Text -> AppConfig
+testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["nonexistent"] }
+
+testCfgExtraSearchPath :: Text -> AppConfig
+testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configDbExtraSearchPath = ["public", "extensions"] }
+
+testCfgRootSpec :: Text -> AppConfig
+testCfgRootSpec testDbConn = (testCfg testDbConn) { configDbRootSpec = Just $ QualifiedIdentifier mempty "root"}
+
+testCfgHtmlRawOutput :: Text -> AppConfig
+testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = ["text/html"] }
+
+testCfgResponseHeaders :: Text -> AppConfig
+testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configDbPreRequest = Just $ QualifiedIdentifier mempty "custom_headers" }
+
+testMultipleSchemaCfg :: Text -> AppConfig
+testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["v1", "v2"] }
+
+testCfgLegacyGucs :: Text -> AppConfig
+testCfgLegacyGucs testDbConn = (testCfg testDbConn) { configDbUseLegacyGucs = False }
+
+analyzeTable :: Text -> Text -> IO ()
+analyzeTable dbConn tableName =
+  void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-a", "-c", toS $ "ANALYZE test.\"" <> tableName <> "\""] []
+
+rangeHdrs :: ByteRange -> [Header]
+rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
+
+rangeHdrsWithCount :: ByteRange -> [Header]
+rangeHdrsWithCount r = ("Prefer", "count=exact") : rangeHdrs r
+
+acceptHdrs :: BS.ByteString -> [Header]
+acceptHdrs mime = [(hAccept, mime)]
+
+rangeUnit :: Header
+rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items")
+
+matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool
+matchHeader name valRegex headers =
+  maybe False (=~ valRegex) $ lookup name headers
+
+noBlankHeader :: [Header] -> Bool
+noBlankHeader = notElem mempty
+
+noProfileHeader :: [Header] -> Bool
+noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers
+
+authHeader :: BS.ByteString -> BS.ByteString -> Header
+authHeader typ creds =
+  (hAuthorization, typ <> " " <> creds)
+
+authHeaderJWT :: BS.ByteString -> Header
+authHeaderJWT = authHeader "Bearer"
+
+-- | Tests whether the text can be parsed as a json object containing
+-- the key "message", and optional keys "details", "hint", "code",
+-- and no extraneous keys
+isErrorFormat :: BL.ByteString -> Bool
+isErrorFormat s =
+  "message" `S.member` keys &&
+    S.null (S.difference keys validKeys)
+ where
+  obj = decode s :: Maybe (M.Map Text Value)
+  keys = maybe S.empty M.keysSet obj
+  validKeys = S.fromList ["message", "details", "hint", "code"]
diff --git a/test/spec/TestTypes.hs b/test/spec/TestTypes.hs
new file mode 100644
--- /dev/null
+++ b/test/spec/TestTypes.hs
@@ -0,0 +1,37 @@
+module TestTypes (
+  IncPK(..)
+, CompoundPK(..)
+) where
+
+import           Data.Aeson ((.:))
+import qualified Data.Aeson as JSON
+
+import Protolude
+
+data IncPK = IncPK {
+  incId          :: Int
+, incNullableStr :: Maybe Text
+, incStr         :: Text
+, incInsert      :: Text
+} deriving (Eq, Show)
+
+instance JSON.FromJSON IncPK where
+  parseJSON (JSON.Object r) = IncPK <$>
+    r .: "id" <*>
+    r .: "nullable_string" <*>
+    r .: "non_nullable_string" <*>
+    r .: "inserted_at"
+  parseJSON _ = mzero
+
+data CompoundPK = CompoundPK {
+  compoundK1    :: Int
+, compoundK2    :: Text
+, compoundExtra :: Maybe Int
+} deriving (Eq, Show)
+
+instance JSON.FromJSON CompoundPK where
+  parseJSON (JSON.Object r) = CompoundPK <$>
+    r .: "k1" <*>
+    r .: "k2" <*>
+    r .: "extra"
+  parseJSON _ = mzero
