packages feed

postgresql-types-0.1: src/library/PostgresqlTypes/Char.hs

module PostgresqlTypes.Char
  ( Char,

    -- * Accessors
    toWord8,
    toChar,

    -- * Constructors
    refineFromWord8,
    normalizeFromWord8,
    refineFromChar,
    normalizeFromChar,
  )
where

import qualified Data.Attoparsec.Text as Attoparsec
import qualified Data.Char
import PostgresqlTypes.Algebra
import PostgresqlTypes.Prelude hiding (Char)
import PostgresqlTypes.Via
import qualified PtrPeeker
import qualified PtrPoker.Write as Write
import qualified Test.QuickCheck as QuickCheck
import qualified TextBuilder

-- | PostgreSQL @\"char\"@ type (note the quotes). Single-byte internal PostgreSQL type.
--
-- This is a special PostgreSQL type that uses only 1 byte of storage and can store
-- a single ASCII character (values 0-127). It is primarily used in PostgreSQL system
-- catalogs as a simplistic enumeration type and is not intended for general-purpose use.
--
-- [PostgreSQL docs](https://www.postgresql.org/docs/18/datatype-character.html).
--
-- __Important distinction:__ This type represents the quoted @\"char\"@ type in PostgreSQL,
-- which is completely different from @char(n)@, @character(n)@, or @bpchar(n)@:
--
-- * @\"char\"@ (this type) — single-byte internal type, 1 byte storage, used in system catalogs
-- * @char(n)@, @character(n)@, @bpchar(n)@ — fixed-length blank-padded strings, represented by 'PostgresqlTypes.Bpchar.Bpchar'
--
-- For example, @\"char\"@ in SQL is 'Char' in Haskell, while @char(1)@ in SQL is
-- @'PostgresqlTypes.Bpchar.Bpchar' 1@ in Haskell. Despite the similar names,
-- these are entirely different types in PostgreSQL.
newtype Char = Char Word8
  deriving newtype (Eq, Ord, Hashable)
  deriving (Show, Read, IsString) via (ViaIsScalar Char)

instance Arbitrary Char where
  arbitrary =
    Char <$> QuickCheck.choose (0, 127)

instance IsScalar Char where
  schemaName = Tagged Nothing
  typeName = Tagged "char"
  baseOid = Tagged (Just 18)
  arrayOid = Tagged (Just 1002)
  typeSignature = Tagged "\"char\""
  binaryEncoder (Char base) =
    Write.word8 base
  binaryDecoder =
    Right . Char <$> PtrPeeker.fixed PtrPeeker.unsignedInt1
  textualEncoder (Char base) =
    TextBuilder.unicodeCodepoint (fromIntegral base)
  textualDecoder = do
    -- PostgreSQL may return empty string for \NUL or stripped spaces
    maybeC <- Attoparsec.option Nothing (Just <$> Attoparsec.anyChar)
    case maybeC of
      Nothing -> pure (Char 0) -- Empty input means \NUL
      Just c -> do
        let charOrd = ord c
        if charOrd > 127
          then fail "Invalid char: value > 127"
          else pure (Char (fromIntegral charOrd))

-- * Accessors

-- | Extract the underlying 'Word8' value.
toWord8 :: Char -> Word8
toWord8 = coerce

-- | Convert PostgreSQL 'Char' to Haskell 'Data.Char.Char'.
toChar :: Char -> Data.Char.Char
toChar (Char word8) = Data.Char.chr (fromIntegral word8)

-- * Constructors

-- | Construct a PostgreSQL 'Char' from 'Word8' with validation.
-- Returns 'Nothing' if the value is greater than 127.
refineFromWord8 :: Word8 -> Maybe Char
refineFromWord8 word8 =
  if word8 > 127
    then Nothing
    else Just (Char word8)

-- | Construct a PostgreSQL 'Char' from 'Word8', clamping to valid range.
-- Values greater than 127 have the high bit cleared.
normalizeFromWord8 :: Word8 -> Char
normalizeFromWord8 word8 = Char (clearBit word8 7)

-- | Construct a PostgreSQL 'Char' from Haskell 'Data.Char.Char' with validation.
-- Returns 'Nothing' if the character's code point is greater than 127.
refineFromChar :: Data.Char.Char -> Maybe Char
refineFromChar char =
  let ord = Data.Char.ord char
   in if ord > 127
        then Nothing
        else Just (Char (fromIntegral ord))

-- | Construct a PostgreSQL 'Char' from Haskell 'Data.Char.Char'.
-- Turns invalid chars into '\NUL'.
normalizeFromChar :: Data.Char.Char -> Char
normalizeFromChar = fromMaybe (Char 0) . refineFromChar