diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright William Yao (c) 2022
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Author name here nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/quibble-core.cabal b/quibble-core.cabal
new file mode 100644
--- /dev/null
+++ b/quibble-core.cabal
@@ -0,0 +1,51 @@
+cabal-version:      2.4
+
+name:           quibble-core
+version:        0.0.0.1
+synopsis:       Convenient SQL query language for Haskell (but only for single tables)
+description:    Provides a way to construct conditions suitable for appending to queries in a type-safe way.
+category:       Database
+homepage:       https://gitlab.com/williamyaoh/quibble/blob/cleanroom/README.md
+bug-reports:    https://gitlab.com/williamyaoh/quibble/issues
+author:         William Yao
+maintainer:     williamyaoh@gmail.com
+copyright:      2022 William Yao
+license:        BSD-3-Clause
+license-file:   LICENSE
+build-type:     Simple
+
+source-repository head
+  type: git
+  location: https://www.gitlab.com/williamyaoh/quibble.git
+
+library
+  exposed-modules:
+    Database.Quibble
+  ghc-options: -Wall -Wcompat -Wincomplete-record-updates
+               -Wincomplete-uni-patterns -Wredundant-constraints
+               -Wnoncanonical-monad-instances -fno-warn-name-shadowing
+  hs-source-dirs: src/lib
+  default-language: Haskell2010
+  build-depends:
+    , base ==4.*
+    , containers <0.7
+    , bytestring <0.12
+    , mono-traversable <1.1
+    , optics-core <0.5
+    , text <1.3
+    , text-conversions <0.4
+    , time <1.14
+    , uuid <1.4
+
+test-suite quibble-core-test
+  type: exitcode-stdio-1.0
+  main-is: spec.hs
+  build-depends:
+      base ==4.*
+    , quibble-core
+    , hedgehog <1.2
+    , tasty <1.5
+    , tasty-hedgehog <1.3
+    , tasty-hunit <0.11
+  hs-source-dirs: test/lib
+  default-language: Haskell2010
diff --git a/src/lib/Database/Quibble.hs b/src/lib/Database/Quibble.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Database/Quibble.hs
@@ -0,0 +1,342 @@
+{-# LANGUAGE AllowAmbiguousTypes    #-}
+{-# LANGUAGE DataKinds              #-}
+{-# LANGUAGE FlexibleInstances      #-}
+{-# LANGUAGE FunctionalDependencies #-}
+{-# LANGUAGE KindSignatures         #-}
+{-# LANGUAGE MultiParamTypeClasses  #-}
+{-# LANGUAGE OverloadedStrings      #-}
+{-# LANGUAGE RankNTypes             #-}
+{-# LANGUAGE ScopedTypeVariables    #-}
+{-# LANGUAGE TypeApplications       #-}
+{-# LANGUAGE TypeFamilies           #-}
+{-# LANGUAGE UndecidableInstances   #-}
+
+module Database.Quibble
+  ( Query
+  , RowsOf
+  , DBWhere(..), DBRender(..)
+  , Expr(..)
+  , SortExpr(..)
+  , HasTable, HasColumn
+  , Inline(..)
+  , query, rowsOf
+  , where_, sortBy, limit, offset
+  , asc, desc
+  , (.&&), (.||), (.==), (./=), true, false
+  , isNull, isNotNull
+  , module Reexport
+  )
+where
+
+import Data.ByteString ( ByteString )
+import Data.ByteString.Builder ( Builder )
+import Data.Foldable ( fold )
+import Data.Int ( Int16, Int32, Int64 )
+import Data.Kind ( Type )
+import Data.MonoTraversable ( MonoFoldable, Element, ofoldMap )
+import Data.Proxy ( Proxy(..) )
+import Data.Sequence ( Seq )
+import Data.String ( IsString(..) )
+import Data.Time ( UTCTime )
+import Data.Time.Format.ISO8601 ( iso8601Show )
+import Data.Text ( Text )
+import Data.Text.Conversions ( convertText, UTF8(..) )
+import Data.UUID ( UUID )
+import Data.Word ( Word16, Word32, Word64 )
+import Optics.Lens ( Lens', lens )
+import Optics.Setter ( over )
+import GHC.OverloadedLabels ( IsLabel(..) )
+import GHC.TypeLits ( KnownSymbol, Symbol, symbolVal )
+
+import qualified Data.ByteString.Builder as Builder
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.Sequence as Seq
+import qualified Data.Text.Lazy as TL
+import qualified Data.UUID as UUID
+
+import Data.Function as Reexport ( (&) )
+
+-- | The constructor is exposed in case you need to unsafely construct
+-- | expressions. But you shouldn't rely on it too much.
+newtype Expr (ctx :: Type) (ty :: Type) =
+  Expr { unExpr :: Builder }
+
+-- | You can think of this as a wrapper around `Expr' that adds sorting direction.
+-- | Unfortunately, we can't allow @ASC NULLS LAST@ or friends here because that's
+-- | not portable across database.
+data SortExpr (ctx :: Type)
+  = Asc Builder
+  | Desc Builder
+  | CustomSort ByteString Builder
+
+-- | Allows us to lookup table name by type.
+class KnownSymbol tbl => HasTable (ctx :: Type) (tbl :: Symbol) | ctx -> tbl
+
+-- | Allows us to infer the column type from the table type and column name.
+class KnownSymbol col => HasColumn (ctx :: Type) (col :: Symbol) (ty :: Type) | ctx col -> ty
+
+data Query (ctx :: Type) = Query
+  { qryWhereCond :: Maybe (Expr ctx Bool)
+  , qryLimit :: Maybe Word64
+  , qryOffset :: Maybe Word64
+  , qrySort :: Seq (SortExpr ctx)
+  }
+
+data RowsOf (ctx :: Type) = RowsOf
+  { rowsWhereCond :: Maybe (Expr ctx Bool) }
+
+sqlSortExpr :: SortExpr ctx -> Builder
+sqlSortExpr sort = case sort of
+  Asc expr -> expr <> " ASC"
+  Desc expr -> expr <> " DESC"
+  CustomSort sortDir expr -> expr <> " " <> Builder.byteString sortDir
+
+sqlQuery :: Query ctx -> ByteString
+sqlQuery qry = BL.toStrict $ Builder.toLazyByteString $ mconcat
+  [ case qryWhereCond qry of
+      Nothing -> mempty
+      Just cond -> "\nWHERE " <> unExpr cond
+  , if Seq.null (qrySort qry)
+      then mempty
+      else "\nSORT BY " <> fold (Seq.intersperse ", " $ fmap sqlSortExpr $ qrySort qry)
+  , case qryLimit qry of
+      Nothing -> mempty
+      Just limit -> "\nLIMIT " <> Builder.word64Dec limit
+  , case qryOffset qry of
+      Nothing -> mempty
+      Just offset -> "\nOFFSET " <> Builder.word64Dec offset
+  ]
+
+sqlRowsOf :: RowsOf ctx -> ByteString
+sqlRowsOf rows = case rowsWhereCond rows of
+  Nothing ->
+    ""
+  Just cond ->
+    BL.toStrict $ Builder.toLazyByteString $
+      "WHERE " <> unExpr cond
+
+asc :: Expr ctx ty -> SortExpr ctx
+asc (Expr bldr) = Asc bldr
+
+desc :: Expr ctx ty -> SortExpr ctx
+desc (Expr bldr) = Desc bldr
+
+-- | A `Query' is meant to represent everything in a @SELECT@ statement, other
+-- | than the columns selection and the joined tables.
+-- | This is meant to be used with @-XTypeApplications@, like @query \@Foo@.
+query :: forall ctx. Query ctx
+query = Query
+  { qryWhereCond = Nothing
+  , qryLimit = Nothing
+  , qryOffset = Nothing
+  , qrySort = Seq.empty
+  }
+
+-- | A `RowsOf' is meant to represent the conditions of an @UPDATE@ or @DELETE@.
+-- | This is meant to be used with @-XTypeApplications@, like @rowsOf \@Foo@.
+rowsOf :: forall ctx. RowsOf ctx
+rowsOf = RowsOf
+  { rowsWhereCond = Nothing
+  }
+
+class DBWhere qry ctx where
+  whereCond :: Lens' qry (Maybe (Expr ctx Bool))
+
+  -- | A convenience function for when the user doesn't want to specify any conditions.
+  allRows :: qry
+
+instance (ctx ~ ctx') => DBWhere (Query ctx) ctx' where
+  whereCond = lens qryWhereCond (\qry cond -> qry { qryWhereCond = cond })
+  allRows = query @ctx
+
+instance (ctx ~ ctx') => DBWhere (RowsOf ctx) ctx' where
+  whereCond = lens rowsWhereCond (\rows cond -> rows { rowsWhereCond = cond })
+  allRows = rowsOf @ctx
+
+class DBRender a where
+  renderSQL :: a -> ByteString
+
+instance DBRender (Query ctx) where
+  renderSQL = sqlQuery
+
+instance DBRender (RowsOf ctx) where
+  renderSQL = sqlRowsOf
+
+-- | Add a condition to the `Query' or `RowsOf'. If @where_@ is used multiple times,
+-- | the condition will be @AND@ed together.
+-- |
+-- | > rowsOf @Foo
+-- | >   & where_ (...)
+where_ :: DBWhere qry ctx => Expr ctx Bool -> qry -> qry
+where_ cond qry =
+  over whereCond
+    (\existing -> case existing of
+      Nothing -> Just cond
+      Just cond' -> Just (cond' .&& cond))
+    qry
+
+-- | Set the number of rows specified by a @LIMIT@. If @limit@ is used multiple
+-- | times, only the result of the last call matters.
+-- |
+-- | > query @Foo
+-- | >   & limit 50
+limit :: Word64 -> Query ctx -> Query ctx
+limit rows qry = qry
+  { qryLimit = Just rows
+  }
+
+-- | Set the number of rows specified by an @OFFSET@. If @offset@ is used
+-- | multiple times, only the result of the last call matters.
+-- |
+-- | > query @Foo
+-- | >   & offset 100
+-- |
+-- | Note that in the general case, your query will still pay the cost of
+-- | looking up all the rows that were skipped! Be careful when using @OFFSET@.
+offset :: Word64 -> Query ctx -> Query ctx
+offset rows qry = qry
+  { qryOffset = Just rows
+  }
+
+-- | Specify how the output should be sorted. If @sortBy@ is used multiple times,
+-- | all sort expressions are concatenated together, with later calls having
+-- | lower sort precedence.
+-- |
+-- | @-XOverloadedLists@ can help make specifying the list of sort expressions
+-- | simpler.
+-- |
+-- | > query @Foo
+-- | >   & sortBy [asc #col1, desc #col2]
+sortBy :: Seq (SortExpr ctx) -> Query ctx -> Query ctx
+sortBy sorts qry = qry
+  { qrySort = qrySort qry <> sorts
+  }
+
+escapeCharSeq :: (MonoFoldable t, Element t ~ Char) => t -> Builder
+escapeCharSeq t =
+  mconcat ["E'", ofoldMap escapeChar t, "'"]
+  where
+    escapeChar :: Char -> Builder
+    escapeChar '\\' = "\\\\"
+    escapeChar '\'' = "''"
+    escapeChar '?'  = "\\x3F"
+    escapeChar c | fromEnum c > 127 = Builder.int32HexFixed (fromIntegral $ fromEnum c)
+    escapeChar other = Builder.char7 other
+
+escapeByteString :: ByteString -> Builder
+escapeByteString bs =
+  mconcat ["'\\x", Builder.byteStringHex bs, "'"]
+
+instance IsString (Expr ctx ByteString) where
+  fromString s =
+    let UTF8 bs = convertText s
+    in Expr (escapeByteString bs)
+
+instance IsString (Expr ctx BL.ByteString) where
+  fromString s =
+    let UTF8 bs = convertText s
+    in Expr (escapeByteString bs)
+
+instance IsString (Expr ctx Text) where
+  fromString s = Expr (escapeCharSeq s)
+
+instance IsString (Expr ctx TL.Text) where
+  fromString s = Expr (escapeCharSeq s)
+
+instance IsString (Expr ctx String) where
+  fromString s = Expr (escapeCharSeq s)
+
+instance (Show a, Num a) => Num (Expr ctx a) where
+  (+) (Expr l) (Expr r) = Expr (mconcat ["(", l, " + ", r, ")"])
+  (-) (Expr l) (Expr r) = Expr (mconcat ["(", l, " - ", r, ")"])
+  (*) (Expr l) (Expr r) = Expr (mconcat ["(", l, " * ", r, ")"])
+  negate (Expr l) = Expr (mconcat ["-", l])
+  abs (Expr x) = Expr (mconcat ["ABS(", x, ")"])
+  signum (Expr x) = Expr (mconcat ["SIGN(", x, ")"])
+  fromInteger i =
+    let UTF8 bs = convertText $ show (fromInteger i :: a)
+    in Expr (Builder.byteString bs)
+
+instance (Show a, Fractional a) => Fractional (Expr ctx a) where
+  (/) (Expr l) (Expr r) = Expr (mconcat ["(", l, " / ", r, ")"])
+  fromRational r =
+    let UTF8 bs = convertText $ show (fromRational r :: a)
+    in Expr (Builder.byteString bs)
+
+-- This instance is not currently Unicode-aware, because we don't use
+-- the quoted syntax for identifiers.
+instance
+  ( KnownSymbol tbl
+  , HasTable ctx tbl
+  , HasColumn ctx col ty
+  ) => IsLabel col (Expr ctx ty) where
+  fromLabel = Expr $ mconcat
+    [ Builder.string7 $ symbolVal $ Proxy @tbl
+    , "."
+    , Builder.string7 $ symbolVal $ Proxy @col
+    ]
+
+true :: Expr ctx Bool
+true = Expr "TRUE"
+
+false :: Expr ctx Bool
+false = Expr "FALSE"
+
+isNull :: Expr ctx a -> Expr ctx Bool
+isNull (Expr x) = Expr (mconcat [x, " IS NULL"])
+
+isNotNull :: Expr ctx a -> Expr ctx Bool
+isNotNull (Expr x) = Expr (mconcat [x, " IS NOT NULL"])
+
+infixl 0 .&&
+(.&&) :: Expr ctx Bool -> Expr ctx Bool -> Expr ctx Bool
+(.&&) (Expr l) (Expr r) = Expr (mconcat ["(", l, " AND ", r, ")"])
+
+infixl 1 .||
+(.||) :: Expr ctx Bool -> Expr ctx Bool -> Expr ctx Bool
+(.||) (Expr l) (Expr r) = Expr (mconcat ["(", l, " OR ", r, ")"])
+
+(./=) :: Expr ctx a -> Expr ctx a -> Expr ctx Bool
+(./=) (Expr l) (Expr r) = Expr (mconcat [l, " <> ", r])
+
+(.==) :: Expr ctx a -> Expr ctx a -> Expr ctx Bool
+(.==) (Expr l) (Expr r) = Expr (mconcat [l, " = ", r])
+
+class Inline ty where
+  inline :: ty -> Expr ctx ty
+
+instance Inline ByteString where
+  inline s = Expr (escapeByteString s)
+instance Inline BL.ByteString where
+  inline s = Expr (escapeByteString $ BL.toStrict s)
+instance Inline Text where
+  inline s = Expr (escapeCharSeq s)
+instance Inline TL.Text where
+  inline s = Expr (escapeCharSeq s)
+instance Inline String where
+  inline s = Expr (escapeCharSeq s)
+
+instance Inline Bool where
+  inline b = if b then true else false
+
+instance Inline Int16 where
+  inline n = Expr (Builder.int16Dec n)
+instance Inline Int32 where
+  inline n = Expr (Builder.int32Dec n)
+instance Inline Int64 where
+  inline n = Expr (Builder.int64Dec n)
+
+instance Inline Word16 where
+  inline n = Expr (Builder.word16Dec n)
+instance Inline Word32 where
+  inline n = Expr (Builder.word32Dec n)
+instance Inline Word64 where
+  inline n = Expr (Builder.word64Dec n)
+
+instance Inline UUID where
+  inline uuid = Expr $ mconcat
+    ["'", Builder.byteString $ UUID.toASCIIBytes uuid, "'"]
+
+instance Inline UTCTime where
+  inline t = Expr $ mconcat
+    ["'", Builder.string7 $ iso8601Show t, "'"]
diff --git a/test/lib/spec.hs b/test/lib/spec.hs
new file mode 100644
--- /dev/null
+++ b/test/lib/spec.hs
@@ -0,0 +1,31 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import Hedgehog ( Property, property, forAll, (===) )
+import Test.Tasty ( TestTree )
+import Test.Tasty.Hedgehog ( testPropertyNamed )
+
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+import qualified Test.Tasty as Tasty
+import qualified Test.Tasty.HUnit as HUnit
+
+main :: IO ()
+main = Tasty.defaultMain tests
+
+tests :: TestTree
+tests = Tasty.testGroup "Tests"
+  [ HUnit.testCase "test passes" $
+      True HUnit.@?= True
+  , testPropertyNamed
+      "property passes"
+      "prop_test"
+      prop_test
+  ]
+
+prop_test :: Property
+prop_test = property $ do
+  a <- forAll $ Gen.int (Range.exponential 0 100)
+  b <- forAll $ Gen.int (Range.exponential 0 100)
+  a + b === b + a
