packages feed

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

module PostgresqlTypes.Polygon
  ( Polygon,

    -- * Accessors
    toPointList,
    toPointVector,

    -- * Constructors
    refineFromPointList,
    refineFromPointVector,
  )
where

import qualified Data.Attoparsec.Text as Attoparsec
import qualified Data.Vector.Unboxed as UnboxedVector
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 Test.QuickCheck as QuickCheck
import qualified TextBuilder

-- | PostgreSQL @polygon@ type. Closed geometric path in 2D plane.
--
-- Represented as a series of vertices (points).
-- The polygon is automatically closed (the last point connects to the first).
-- Stored as the number of points followed by the point coordinates.
--
-- [PostgreSQL docs](https://www.postgresql.org/docs/18/datatype-geometric.html#DATATYPE-POLYGON).
newtype Polygon
  = Polygon (UnboxedVector.Vector (Double, Double))
  deriving stock (Eq, Ord)
  deriving (Show, Read, IsString) via (ViaIsScalar Polygon)

instance Arbitrary Polygon where
  arbitrary = do
    size <- QuickCheck.getSize
    -- Polygons need at least 3 points
    numPoints <- QuickCheck.choose (3, max 3 size)
    points <- UnboxedVector.fromList <$> QuickCheck.vectorOf numPoints arbitrary
    pure (Polygon points)

  shrink (Polygon points) = [Polygon points' | points' <- shrink points, UnboxedVector.length points' >= 3]

instance Hashable Polygon where
  hashWithSalt salt (Polygon points) =
    salt `hashWithSalt` UnboxedVector.toList points

instance IsScalar Polygon where
  schemaName = Tagged Nothing
  typeName = Tagged "polygon"
  baseOid = Tagged (Just 604)
  arrayOid = Tagged (Just 1027)
  typeParams = Tagged []
  binaryEncoder (Polygon points) =
    let numPoints = fromIntegral (UnboxedVector.length points) :: Int32
        pointsEncoded = UnboxedVector.foldMap encodePoint points
     in mconcat
          [ Write.bInt32 numPoints,
            pointsEncoded
          ]
    where
      encodePoint (x, y) =
        mconcat
          [ Write.bWord64 (castDoubleToWord64 x),
            Write.bWord64 (castDoubleToWord64 y)
          ]
  binaryDecoder = do
    numPoints <- PtrPeeker.fixed PtrPeeker.beSignedInt4
    points <- UnboxedVector.replicateM (fromIntegral numPoints) decodePoint
    pure (Right (Polygon points))
    where
      decodePoint = PtrPeeker.fixed do
        x <- castWord64ToDouble <$> PtrPeeker.beUnsignedInt8
        y <- castWord64ToDouble <$> PtrPeeker.beUnsignedInt8
        pure (x, y)
  textualEncoder (Polygon points) =
    "(" <> TextBuilder.intercalateMap "," encodePoint (UnboxedVector.toList points) <> ")"
    where
      encodePoint (x, y) =
        "(" <> TextBuilder.string (printf "%g" x) <> "," <> TextBuilder.string (printf "%g" y) <> ")"
  textualDecoder = do
    _ <- Attoparsec.char '('
    points <- parsePoint `Attoparsec.sepBy1` Attoparsec.char ','
    _ <- Attoparsec.char ')'
    pure (Polygon (UnboxedVector.fromList points))
    where
      parsePoint = do
        _ <- Attoparsec.char '('
        x <- Attoparsec.double
        _ <- Attoparsec.char ','
        y <- Attoparsec.double
        _ <- Attoparsec.char ')'
        pure (x, y)

-- * Accessors

-- | Extract the polygon points as a list.
toPointList :: Polygon -> [(Double, Double)]
toPointList (Polygon points) = UnboxedVector.toList points

-- | Extract the polygon points as an unboxed vector.
toPointVector :: Polygon -> UnboxedVector.Vector (Double, Double)
toPointVector (Polygon points) = points

-- * Constructors

-- | Construct a PostgreSQL 'Polygon' from a list of points with validation.
-- Returns 'Nothing' if there are fewer than 3 points.
refineFromPointList :: [(Double, Double)] -> Maybe Polygon
refineFromPointList = refineFromPointVector . UnboxedVector.fromList

-- | Construct a PostgreSQL 'Polygon' from an unboxed vector of points with validation.
-- Returns 'Nothing' if there are fewer than 3 points.
refineFromPointVector :: UnboxedVector.Vector (Double, Double) -> Maybe Polygon
refineFromPointVector vector =
  if UnboxedVector.length vector >= 3
    then Just (Polygon vector)
    else Nothing