packages feed

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

module PostgresqlTypes.Range
  ( Range,

    -- * Accessors
    isEmpty,
    fold,

    -- * Constructors
    empty,
    unbounded,
    normalizeBounded,
    refineBounded,

    -- ** Combinators
    mergeIfOverlappingOrAdjacent,
  )
where

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

-- |
-- Normalized representation of a range in the @[inclusiveLowerBound,exclusiveUpperBound)@ form.
--
-- Although PostgreSQL has the concept of inclusive and exclusive bounds in ranges in reality it always normalizes the range values to one form.
-- The lower bound is inclusive and the upper bound is exclusive with one exception: if the lower bound is infinity then it is treated as exclusive.
-- There is also another special value: empty.
--
-- The following standard types are supported via the 'IsRangeElement' instances:
--
-- - @int4range@ - @Range Int4@
-- - @int8range@ - @Range Int8@
-- - @numrange@ - @Range Numeric@
-- - @tsrange@ - @Range Timestamp@
-- - @tstzrange@ - @Range Timestamptz@
-- - @daterange@ - @Range Date@
--
-- You can also define your own.
--
-- [PostgreSQL docs](https://www.postgresql.org/docs/18/rangetypes.html).
data Range a
  = EmptyRange
  | BoundedRange (Maybe a) (Maybe a)
  deriving stock (Eq, Functor)
  deriving (Show, Read, IsString) via (ViaIsScalar (Range a))

instance (IsRangeElement a) => IsScalar (Range a) where
  schemaName = Tagged Nothing
  typeName = retag (rangeTypeName @a)
  baseOid = retag (rangeBaseOid @a)
  arrayOid = retag (rangeArrayOid @a)
  typeParams = retag (typeParams @a)
  binaryEncoder = \case
    EmptyRange ->
      Write.word8 0b00000001
    BoundedRange lowerValue upperValue ->
      mconcat
        [ Write.word8
            ( (if null lowerValue then 0b00001000 else 0b00000010)
                .|. (if null upperValue then 0b00010000 else 0)
            ),
          foldMap renderBound lowerValue,
          foldMap renderBound upperValue
        ]
    where
      renderBound bound =
        let write = binaryEncoder bound
         in Write.bWord32 (fromIntegral (Write.writeSize write)) <> write

  binaryDecoder = runExceptT do
    flags <- lift do
      PtrPeeker.fixed PtrPeeker.unsignedInt1
    let emptyRange = testBit flags 0
        lowerInclusive = testBit flags 1
        upperInclusive = testBit flags 2
        lowerInfinite = testBit flags 3
        upperInfinite = testBit flags 4
    if emptyRange
      then pure EmptyRange
      else do
        lowerValue <- decodeBound lowerInfinite
        upperValue <- decodeBound upperInfinite

        when (isJust lowerValue && not lowerInclusive) do
          throwError (DecodingError ["lower-value"] (UnsupportedValueDecodingErrorReason "Lower bound cannot be exclusive when it is not infinite" (TextBuilder.toText (TextBuilder.decimal flags))))

        when (isJust upperValue && upperInclusive) do
          throwError (DecodingError ["upper-value"] (UnsupportedValueDecodingErrorReason "Upper bound cannot be inclusive" (TextBuilder.toText (TextBuilder.decimal flags))))

        case (lowerValue, upperValue) of
          (Just lv, Just uv) ->
            when (lv >= uv) do
              throwError (DecodingError ["upper-value"] (UnsupportedValueDecodingErrorReason "Upper value is smaller than the lower one" (TextBuilder.toText (TextBuilder.decimal flags))))
          _ -> pure ()

        pure (BoundedRange lowerValue upperValue)
    where
      decodeBound infinite =
        if infinite
          then pure Nothing
          else
            Just <$> do
              size <- lift do
                PtrPeeker.fixed PtrPeeker.beSignedInt4
              when (size < 0) do
                throwError (DecodingError ["bound-size"] (UnsupportedValueDecodingErrorReason "Expecting >= 0" (TextBuilder.toText (TextBuilder.decimal size))))
              ExceptT do
                PtrPeeker.forceSize (fromIntegral size) do
                  binaryDecoder @a

  textualEncoder = \case
    EmptyRange -> "empty"
    BoundedRange lowerValue upperValue ->
      mconcat
        [ case lowerValue of
            Nothing -> "("
            Just lowerValue -> "[" <> textualEncoder lowerValue,
          ",",
          case upperValue of
            Nothing -> ")"
            Just upperValue -> textualEncoder upperValue <> ")"
        ]
  textualDecoder =
    parseEmpty <|> parseBounded
    where
      parseEmpty = EmptyRange <$ Attoparsec.string "empty"
      parseBounded = do
        lowerBracket <- Attoparsec.satisfy (\c -> c == '[' || c == '(')
        Attoparsec.skipSpace
        lowerValue <-
          if lowerBracket == '['
            then Just <$> parseElement
            else pure Nothing
        Attoparsec.skipSpace
        _ <- Attoparsec.char ','
        upperValue <- optional parseElement
        _ <- Attoparsec.char ')'
        pure (BoundedRange lowerValue upperValue)

      -- Parse element that might be quoted by PostgreSQL (for extreme dates)
      parseElement =
        quotedElement <|> textualDecoder @a
        where
          quotedElement = Attoparsec.char '"' *> textualDecoder @a <* Attoparsec.char '"'

instance (Arbitrary a, Ord a) => Arbitrary (Range a) where
  arbitrary =
    QuickCheck.frequency
      [ (1, pure EmptyRange),
        ( 10,
          do
            value1 <- QuickCheck.frequency [(1, pure Nothing), (10, Just <$> arbitrary)]
            value2 <-
              QuickCheck.frequency
                [ (1, pure Nothing),
                  ( 10,
                    Just <$> case value1 of
                      Nothing -> arbitrary
                      Just value1 -> QuickCheck.suchThat arbitrary (/= value1)
                  )
                ]
            pure if value1 < value2 then BoundedRange value1 value2 else BoundedRange value2 value1
        )
      ]

instance (Hashable a) => Hashable (Range a) where
  hashWithSalt salt = \case
    EmptyRange -> salt `hashWithSalt` (0 :: Int)
    BoundedRange lower upper -> salt `hashWithSalt` (1 :: Int) `hashWithSalt` lower `hashWithSalt` upper

instance (Ord a) => Ord (Range a) where
  compare EmptyRange EmptyRange = EQ
  compare EmptyRange (BoundedRange _ _) = LT
  compare (BoundedRange _ _) EmptyRange = GT
  compare (BoundedRange lower1 upper1) (BoundedRange lower2 upper2) =
    case compareBounds lower1 lower2 of
      EQ -> compareBounds upper1 upper2
      other -> other
    where
      compareBounds Nothing Nothing = EQ
      compareBounds Nothing (Just _) = LT
      compareBounds (Just _) Nothing = GT
      compareBounds (Just v1) (Just v2) = compare v1 v2

isEmpty :: Range a -> Bool
isEmpty = \case
  EmptyRange -> True
  BoundedRange _ _ -> False

-- | Pattern matching on 'Range'.
fold ::
  -- | Empty range case.
  b ->
  -- | Bounded range case.
  (Maybe a -> Maybe a -> b) ->
  (Range a -> b)
fold emptyCase boundedCase = \case
  EmptyRange -> emptyCase
  BoundedRange lower upper -> boundedCase lower upper

-- | Constructs an empty range.
empty :: Range a
empty = EmptyRange

-- | Constructs a range without bounds (infinity to infinity).
unbounded :: Range a
unbounded = BoundedRange Nothing Nothing

-- | Constructs a bounded range normalizing the bounds.
-- If the lower bound is not less than the upper bound, returns 'empty'.
normalizeBounded :: (Ord a) => Maybe a -> Maybe a -> Range a
normalizeBounded = \case
  Just lower -> \case
    Just upper ->
      if lower < upper
        then BoundedRange (Just lower) (Just upper)
        else EmptyRange
    Nothing -> BoundedRange (Just lower) Nothing
  Nothing -> \case
    Just upper -> BoundedRange Nothing (Just upper)
    Nothing -> BoundedRange Nothing Nothing

-- | Constructs a bounded range refining the bounds.
-- If the lower bound is not less than the upper bound, returns 'Nothing'.
refineBounded :: (Ord a) => Maybe a -> Maybe a -> Maybe (Range a)
refineBounded = \case
  Just lower -> \case
    Just upper ->
      if lower < upper
        then Just (BoundedRange (Just lower) (Just upper))
        else Nothing
    Nothing -> Just (BoundedRange (Just lower) Nothing)
  Nothing -> \case
    Just upper -> Just (BoundedRange Nothing (Just upper))
    Nothing -> Just (BoundedRange Nothing Nothing)

-- | Merge two ranges if they are overlapping or adjacent.
-- Returns 'Just' the merged range if they overlap or are adjacent, 'Nothing' otherwise.
--
-- Ranges are normalized as [lower, upper) (inclusive lower, exclusive upper).
-- Two ranges are adjacent if one ends exactly where the other begins.
-- Two ranges overlap if they share any values.
mergeIfOverlappingOrAdjacent :: (Ord a) => Range a -> Range a -> Maybe (Range a)
mergeIfOverlappingOrAdjacent = \case
  EmptyRange -> Just
  BoundedRange lower1 upper1 -> \case
    EmptyRange -> Just (BoundedRange lower1 upper1)
    BoundedRange lower2 upper2 ->
      -- Check if ranges overlap or are adjacent:
      -- r1 = [lower1, upper1), r2 = [lower2, upper2)
      -- They overlap or are adjacent if upper1 >= lower2
      case (upper1, lower2) of
        -- r1 extends to infinity, so always overlaps with or is adjacent to r2
        (Nothing, _) -> Just (BoundedRange lower1 Nothing)
        -- r2 starts from -infinity
        -- If we're processing in sorted order, this shouldn't happen
        -- (lower1 can't be >= -infinity when lower2 is -infinity)
        (Just _, Nothing) ->
          -- However, if it does occur, these ranges must overlap
          -- Result is (-infinity, max(upper1, upper2))
          Just (BoundedRange Nothing (maxBound upper1 upper2))
        (Just u1, Just l2) ->
          -- Both bounds are finite
          -- Overlapping: u1 > l2 (r1 extends into r2)
          -- Adjacent: u1 == l2 (r1 ends exactly where r2 begins)
          if u1 >= l2
            then Just (BoundedRange lower1 (maxBound upper1 upper2))
            else Nothing
  where
    -- Helper to get the max of two Maybe bounds (Nothing represents infinity)
    maxBound Nothing _ = Nothing
    maxBound _ Nothing = Nothing
    maxBound (Just a) (Just b) = Just (max a b)