packages feed

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

module PostgresqlTypes.Box
  ( Box,

    -- * Accessors
    toX1,
    toY1,
    toX2,
    toY2,

    -- * Constructors
    normalizeFromCorners,
    refineFromCorners,
  )
where

import qualified Data.Attoparsec.Text as Attoparsec
import GHC.Float (castDoubleToWord64, castWord64ToDouble)
import PostgresqlTypes.Algebra
import PostgresqlTypes.Prelude
import PostgresqlTypes.Via
import qualified PtrPeeker
import qualified PtrPoker.Write as Write
import qualified TextBuilder

-- | PostgreSQL @box@ type. Rectangular box in 2D plane.
--
-- Rectangular box defined by two opposite corners.
-- Stored as four @64@-bit floating point numbers (@x1@,@y1@),(@x2@,@y2@) in PostgreSQL.
-- The box is normalized so that @x1 <= x2@ and @y1 <= y2@.
--
-- [PostgreSQL docs](https://www.postgresql.org/docs/18/datatype-geometric.html#DATATYPE-GEOMETRIC-BOXES).
data Box
  = Box
      -- | Lower-left x coordinate
      Double
      -- | Lower-left y coordinate
      Double
      -- | Upper-right x coordinate
      Double
      -- | Upper-right y coordinate
      Double
  deriving stock (Eq, Ord)
  deriving (Show, Read, IsString) via (ViaIsScalar Box)

instance Arbitrary Box where
  arbitrary = do
    x1 <- arbitrary
    y1 <- arbitrary
    x2 <- arbitrary
    y2 <- arbitrary
    pure (normalizeFromCorners x1 y1 x2 y2)

  shrink (Box x1 y1 x2 y2) =
    [ normalizeFromCorners x1' y1' x2' y2'
    | (x1', y1', x2', y2') <- shrink (x1, y1, x2, y2)
    ]

instance Hashable Box where
  hashWithSalt salt (Box x1 y1 x2 y2) =
    salt
      `hashWithSalt` castDoubleToWord64 x1
      `hashWithSalt` castDoubleToWord64 y1
      `hashWithSalt` castDoubleToWord64 x2
      `hashWithSalt` castDoubleToWord64 y2

instance IsScalar Box where
  schemaName = Tagged Nothing
  typeName = Tagged "box"
  baseOid = Tagged (Just 603)
  arrayOid = Tagged (Just 1020)
  typeParams = Tagged []
  binaryEncoder (Box x1 y1 x2 y2) =
    mconcat
      [ Write.bWord64 (castDoubleToWord64 x2),
        Write.bWord64 (castDoubleToWord64 y2),
        Write.bWord64 (castDoubleToWord64 x1),
        Write.bWord64 (castDoubleToWord64 y1)
      ]
  binaryDecoder = do
    x2 <- PtrPeeker.fixed (castWord64ToDouble <$> PtrPeeker.beUnsignedInt8)
    y2 <- PtrPeeker.fixed (castWord64ToDouble <$> PtrPeeker.beUnsignedInt8)
    x1 <- PtrPeeker.fixed (castWord64ToDouble <$> PtrPeeker.beUnsignedInt8)
    y1 <- PtrPeeker.fixed (castWord64ToDouble <$> PtrPeeker.beUnsignedInt8)
    pure (Right (Box x1 y1 x2 y2))
  textualEncoder (Box x1 y1 x2 y2) =
    -- PostgreSQL returns coordinates as (upper-right),(lower-left)
    -- So we output (x2,y2),(x1,y1)
    mconcat
      [ "(",
        TextBuilder.string (printf "%g" x2),
        ",",
        TextBuilder.string (printf "%g" y2),
        "),(",
        TextBuilder.string (printf "%g" x1),
        ",",
        TextBuilder.string (printf "%g" y1),
        ")"
      ]
  textualDecoder = do
    _ <- Attoparsec.char '('
    x1 <- Attoparsec.double
    _ <- Attoparsec.char ','
    y1 <- Attoparsec.double
    _ <- Attoparsec.char ')'
    _ <- Attoparsec.char ','
    _ <- Attoparsec.char '('
    x2 <- Attoparsec.double
    _ <- Attoparsec.char ','
    y2 <- Attoparsec.double
    _ <- Attoparsec.char ')'
    -- PostgreSQL may return coordinates in any order, normalize to ensure x1 <= x2 and y1 <= y2
    pure (normalizeFromCorners x1 y1 x2 y2)

-- * Accessors

-- | Extract the lower-left x coordinate.
toX1 :: Box -> Double
toX1 (Box x1 _ _ _) = x1

-- | Extract the lower-left y coordinate.
toY1 :: Box -> Double
toY1 (Box _ y1 _ _) = y1

-- | Extract the upper-right x coordinate.
toX2 :: Box -> Double
toX2 (Box _ _ x2 _) = x2

-- | Extract the upper-right y coordinate.
toY2 :: Box -> Double
toY2 (Box _ _ _ y2) = y2

-- * Constructors

-- | Construct a PostgreSQL 'Box' from corners (x1, y1, x2, y2).
-- Normalizes coordinates to ensure lowerX <= upperX and lowerY <= upperY.
normalizeFromCorners :: Double -> Double -> Double -> Double -> Box
normalizeFromCorners x1 y1 x2 y2 =
  if x1 <= x2
    then
      if y1 <= y2
        then Box x1 y1 x2 y2
        else Box x1 y2 x2 y1
    else
      if y1 <= y2
        then Box x2 y1 x1 y2
        else Box x2 y2 x1 y1

-- | Construct a PostgreSQL 'Box' from corners (lowerX, lowerY, upperX, upperY) with validation.
-- Returns 'Nothing' if lowerX > upperX or lowerY > upperY.
refineFromCorners :: Double -> Double -> Double -> Double -> Maybe Box
refineFromCorners x1 y1 x2 y2 =
  if x1 <= x2 && y1 <= y2
    then Just (Box x1 y1 x2 y2)
    else Nothing