packages feed

postgresql-types 0.1.1.1 → 0.1.2

raw patch · 7 files changed

+465/−8 lines, 7 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ PostgresqlTypes: data Tsvector
+ PostgresqlTypes.Tsvector: AWeight :: Weight
+ PostgresqlTypes.Tsvector: BWeight :: Weight
+ PostgresqlTypes.Tsvector: CWeight :: Weight
+ PostgresqlTypes.Tsvector: DWeight :: Weight
+ PostgresqlTypes.Tsvector: data Tsvector
+ PostgresqlTypes.Tsvector: data Weight
+ PostgresqlTypes.Tsvector: instance Data.Hashable.Class.Hashable PostgresqlTypes.Tsvector.Tsvector
+ PostgresqlTypes.Tsvector: instance Data.Hashable.Class.Hashable PostgresqlTypes.Tsvector.Weight
+ PostgresqlTypes.Tsvector: instance Data.String.IsString PostgresqlTypes.Tsvector.Tsvector
+ PostgresqlTypes.Tsvector: instance GHC.Classes.Eq PostgresqlTypes.Tsvector.Tsvector
+ PostgresqlTypes.Tsvector: instance GHC.Classes.Eq PostgresqlTypes.Tsvector.Weight
+ PostgresqlTypes.Tsvector: instance GHC.Classes.Ord PostgresqlTypes.Tsvector.Tsvector
+ PostgresqlTypes.Tsvector: instance GHC.Classes.Ord PostgresqlTypes.Tsvector.Weight
+ PostgresqlTypes.Tsvector: instance GHC.Enum.Bounded PostgresqlTypes.Tsvector.Weight
+ PostgresqlTypes.Tsvector: instance GHC.Enum.Enum PostgresqlTypes.Tsvector.Weight
+ PostgresqlTypes.Tsvector: instance GHC.Read.Read PostgresqlTypes.Tsvector.Tsvector
+ PostgresqlTypes.Tsvector: instance GHC.Read.Read PostgresqlTypes.Tsvector.Weight
+ PostgresqlTypes.Tsvector: instance GHC.Show.Show PostgresqlTypes.Tsvector.Tsvector
+ PostgresqlTypes.Tsvector: instance GHC.Show.Show PostgresqlTypes.Tsvector.Weight
+ PostgresqlTypes.Tsvector: instance PostgresqlTypes.Algebra.IsScalar PostgresqlTypes.Tsvector.Tsvector
+ PostgresqlTypes.Tsvector: instance Test.QuickCheck.Arbitrary.Arbitrary PostgresqlTypes.Tsvector.Tsvector
+ PostgresqlTypes.Tsvector: instance Test.QuickCheck.Arbitrary.Arbitrary PostgresqlTypes.Tsvector.Weight
+ PostgresqlTypes.Tsvector: normalizeFromLexemeList :: [(Text, [(Word16, Weight)])] -> Tsvector
+ PostgresqlTypes.Tsvector: refineFromLexemeList :: [(Text, [(Word16, Weight)])] -> Maybe Tsvector
+ PostgresqlTypes.Tsvector: toLexemeList :: Tsvector -> [(Text, [(Word16, Weight)])]

Files

README.md view
@@ -5,10 +5,6 @@  Precise Haskell representations of PostgreSQL data types with mappings to and from both binary and textual formats. -## Status--In active development, but already working and exhaustively tested.- ## Key Features  ### Ecosystem Integration@@ -32,6 +28,7 @@ - **Bit string types**: Bit, Varbit - **UUID type**: Uuid - **JSON types**: Json, Jsonb+- **Full-text search types**: Tsvector - **Key-value types**: Hstore - **Range types**: Range (supporting int4range, int8range, numrange, tsrange, tstzrange, daterange) - **Multirange types**: Multirange (supporting int4multirange, int8multirange, etc.)
postgresql-types.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: postgresql-types-version: 0.1.1.1+version: 0.1.2 category: PostgreSQL, Codecs synopsis: Precise PostgreSQL types representation and driver-agnostic codecs description:@@ -134,6 +134,7 @@     PostgresqlTypes.Polygon     PostgresqlTypes.Range     PostgresqlTypes.Text+    PostgresqlTypes.Tsvector     PostgresqlTypes.Time     PostgresqlTypes.Timestamp     PostgresqlTypes.Timestamptz@@ -240,6 +241,7 @@     PostgresqlTypes.RangeSpec     PostgresqlTypes.TextSpec     PostgresqlTypes.TimeSpec+    PostgresqlTypes.TsvectorSpec     PostgresqlTypes.TimestampSpec     PostgresqlTypes.TimestamptzSpec     PostgresqlTypes.TimetzSpec
src/integration-tests/Main.hs view
@@ -71,6 +71,7 @@           withType @PostgresqlTypes.Timestamp [mappingSpec]           withType @PostgresqlTypes.Timestamptz [mappingSpec]           withType @PostgresqlTypes.Timetz [mappingSpec]+          withType @PostgresqlTypes.Tsvector [mappingSpec]           withType @PostgresqlTypes.Uuid [mappingSpec]           withType @(PostgresqlTypes.Varbit 0) [mappingSpec]           withType @(PostgresqlTypes.Varbit 128) [mappingSpec]@@ -125,6 +126,7 @@           withType @PostgresqlTypes.Timestamp [mappingSpec]           withType @PostgresqlTypes.Timestamptz [mappingSpec]           withType @PostgresqlTypes.Timetz [mappingSpec]+          withType @PostgresqlTypes.Tsvector [mappingSpec]           withType @PostgresqlTypes.Uuid [mappingSpec]           withType @(PostgresqlTypes.Varbit 0) [mappingSpec]           withType @(PostgresqlTypes.Varbit 128) [mappingSpec]@@ -179,6 +181,7 @@           withType @PostgresqlTypes.Timestamp [mappingSpec]           withType @PostgresqlTypes.Timestamptz [mappingSpec]           withType @PostgresqlTypes.Timetz [mappingSpec]+          withType @PostgresqlTypes.Tsvector [mappingSpec]           withType @PostgresqlTypes.Uuid [mappingSpec]           withType @(PostgresqlTypes.Varbit 0) [mappingSpec]           withType @(PostgresqlTypes.Varbit 128) [mappingSpec]
src/library/PostgresqlTypes.hs view
@@ -60,6 +60,9 @@     -- * Key-Value Types     Hstore, +    -- * Full-Text Search Types+    Tsvector,+     -- * Range Types     Range,     Multirange,@@ -102,6 +105,7 @@ import PostgresqlTypes.Timestamp import PostgresqlTypes.Timestamptz import PostgresqlTypes.Timetz+import PostgresqlTypes.Tsvector import PostgresqlTypes.Uuid import PostgresqlTypes.Varbit import PostgresqlTypes.Varchar
src/library/PostgresqlTypes/Multirange/QuickCheckGen.hs view
@@ -1,9 +1,8 @@-module PostgresqlTypes.Multirange.QuickCheckGen-where+module PostgresqlTypes.Multirange.QuickCheckGen where +import qualified Data.Set as Set import PostgresqlTypes.Prelude import Test.QuickCheck-import qualified Data.Set as Set  -- | Attention. This generator may run indefinitely if the 'elementGen' has too small variety of possible values. setOfSize :: (Ord a) => Int -> Gen a -> Gen (Set.Set a)
+ src/library/PostgresqlTypes/Tsvector.hs view
@@ -0,0 +1,289 @@+module PostgresqlTypes.Tsvector+  ( Tsvector,++    -- * Accessors+    toLexemeList,++    -- * Constructors+    refineFromLexemeList,+    normalizeFromLexemeList,++    -- * Weight+    Weight (..),+  )+where++import qualified Data.Attoparsec.Text as Attoparsec+import qualified Data.ByteString as ByteString+import qualified Data.List as List+import qualified Data.Map.Strict as Map+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text.Encoding+import qualified Data.Vector as Vector+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++-- | Weight of a tsvector lexeme position.+data Weight = AWeight | BWeight | CWeight | DWeight+  deriving stock (Eq, Ord, Show, Read, Enum, Bounded)++instance Arbitrary Weight where+  arbitrary = QuickCheck.elements [AWeight, BWeight, CWeight, DWeight]++instance Hashable Weight where+  hashWithSalt salt = hashWithSalt salt . fromEnum++-- | PostgreSQL @tsvector@ type. Full-text search document representation.+--+-- A tsvector is a sorted list of distinct lexemes with optional position and weight information.+-- Lexemes are sorted alphabetically and deduplicated, matching PostgreSQL's canonical representation.+--+-- [PostgreSQL docs](https://www.postgresql.org/docs/18/datatype-textsearch.html).+data Tsvector = Tsvector (Vector (Text, Vector (Word16, Weight)))+  deriving stock (Eq, Ord)+  deriving (Show, Read, IsString) via (ViaIsScalar Tsvector)++instance Hashable Tsvector where+  hashWithSalt salt (Tsvector lexemes) =+    Vector.foldl' (\s (t, ps) -> Vector.foldl' (\s' (p, w) -> s' `hashWithSalt` p `hashWithSalt` fromEnum w) (s `hashWithSalt` t) ps) salt lexemes++instance Arbitrary Tsvector where+  arbitrary = do+    size <- QuickCheck.getSize+    numLexemes <- QuickCheck.choose (0, max 0 size)+    lexemeMap <-+      Map.fromList <$> QuickCheck.vectorOf numLexemes do+        -- Generate a non-empty lexeme token without NUL characters+        token <-+          Text.pack+            <$> QuickCheck.listOf1+              (QuickCheck.suchThat arbitrary (\c -> c /= '\NUL'))+        numPositions <- QuickCheck.choose (0, 3)+        positions <-+          sortAndDedupPositions <$> QuickCheck.vectorOf numPositions do+            pos <- QuickCheck.choose (1, 16383)+            weight <- arbitrary+            pure (pos, weight)+        pure (token, positions)+    -- Sort by lexeme (Map.toAscList) and deduplicate (Map guarantees unique keys)+    let sorted = Map.toAscList lexemeMap+    pure (Tsvector (Vector.fromList (map (\(t, ps) -> (t, Vector.fromList ps)) sorted)))+  shrink (Tsvector lexemes) =+    map (\ls -> normalizeLexemes (map (\(t, ps) -> (t, Vector.fromList ps)) ls)) $+      QuickCheck.shrinkList+        ( \(tok, positions) -> do+            shrunkenTok <- QuickCheck.shrinkMap Text.pack Text.unpack tok+            shrunkenPositions <- QuickCheck.shrinkList shrink positions+            pure (shrunkenTok, shrunkenPositions)+        )+        (map (\(t, ps) -> (t, Vector.toList ps)) (Vector.toList lexemes))++-- | Sort lexemes alphabetically and deduplicate by lexeme text, merging positions.+-- Positions within each lexeme are sorted by position number and deduplicated+-- (keeping the highest weight for duplicate positions), matching PostgreSQL's canonical form.+normalizeLexemes :: [(Text, Vector (Word16, Weight))] -> Tsvector+normalizeLexemes lexemes =+  let m = Map.fromListWith (<>) (map (\(t, ps) -> (t, Vector.toList ps)) lexemes)+      sorted = Map.toAscList m+   in Tsvector (Vector.fromList (map (\(t, ps) -> (t, Vector.fromList (sortAndDedupPositions ps))) sorted))++-- | Sort positions by position number ascending, deduplicating by position+-- (keeping the minimum weight, i.e. highest priority: A < B < C < D).+sortAndDedupPositions :: [(Word16, Weight)] -> [(Word16, Weight)]+sortAndDedupPositions =+  map (foldr1 (\(p, w1) (_, w2) -> (p, min w1 w2)))+    . List.groupBy (\a b -> fst a == fst b)+    . List.sortOn fst++instance IsScalar Tsvector where+  schemaName = Tagged Nothing+  typeName = Tagged "tsvector"+  baseOid = Tagged (Just 3614)+  arrayOid = Tagged (Just 3643)+  typeParams = Tagged []++  -- Binary format:+  -- 4 bytes: number of lexemes (int32)+  -- Per lexeme:+  --   N bytes: lexeme text as null-terminated UTF-8 string+  --   2 bytes: number of positions (uint16)+  --   Per position:+  --     2 bytes: uint16 where bits 14-15 = weight (A=3,B=2,C=1,D=0), bits 0-13 = position+  binaryEncoder (Tsvector lexemes) =+    Write.bInt32 (fromIntegral (Vector.length lexemes))+      <> Vector.foldMap encodeLexeme lexemes+    where+      encodeLexeme (token, positions) =+        let tokenBytes = Text.Encoding.encodeUtf8 token+         in Write.byteString tokenBytes+              <> Write.word8 0 -- null terminator+              <> Write.bWord16 (fromIntegral (Vector.length positions))+              <> Vector.foldMap encodePosition positions+      encodePosition (pos, weight) =+        let weightBits = case weight of+              AWeight -> 3+              BWeight -> 2+              CWeight -> 1+              DWeight -> 0+            -- PostgreSQL tsvector positions must be in the range 1..16383.+            -- Clamp here to avoid silent truncation by bit masking.+            posClamped = max 1 (min 16383 pos)+         in Write.bWord16 ((weightBits `shiftL` 14) .|. posClamped)++  binaryDecoder = runExceptT do+    numLexemes <- lift $ PtrPeeker.fixed PtrPeeker.beSignedInt4+    when (numLexemes < 0) do+      throwError+        ( DecodingError+            { location = ["tsvector", "lexemeCount"],+              reason =+                ParsingDecodingErrorReason+                  (fromString "Negative lexeme count in tsvector binary data")+                  ByteString.empty+            }+        )+    lexemes <- Vector.fromList <$> replicateM (fromIntegral numLexemes) decodeLexeme+    pure (Tsvector lexemes)+    where+      decodeLexeme = do+        -- Read null-terminated UTF-8 string+        tokenBytes <- lift PtrPeeker.nullTerminatedStringAsByteString+        case Text.Encoding.decodeUtf8' tokenBytes of+          Left e ->+            throwError+              ( DecodingError+                  { location = ["tsvector", "lexeme"],+                    reason = ParsingDecodingErrorReason (fromString (show e)) tokenBytes+                  }+              )+          Right token+            | Text.null token ->+                throwError+                  ( DecodingError+                      { location = ["tsvector", "lexeme"],+                        reason =+                          ParsingDecodingErrorReason+                            (fromString "empty lexeme is not allowed in tsvector")+                            tokenBytes+                      }+                  )+            | otherwise -> do+                numPositions <- lift $ PtrPeeker.fixed PtrPeeker.beUnsignedInt2+                positions <-+                  Vector.fromList <$> replicateM (fromIntegral numPositions) do+                    posWord <- lift $ PtrPeeker.fixed PtrPeeker.beUnsignedInt2+                    let weightBits = (posWord `shiftR` 14) .&. 0x3+                    let weight = case weightBits of+                          3 -> AWeight+                          2 -> BWeight+                          1 -> CWeight+                          _ -> DWeight+                    let pos = posWord .&. 0x3FFF+                    pure (pos, weight)+                pure (token, positions)++  -- Text format: 'lexeme1':1A,2B 'lexeme2':3C+  -- Single quotes are escaped as '', backslashes as \\+  textualEncoder (Tsvector lexemes) =+    TextBuilder.intercalateMap " " encodeLexeme (Vector.toList lexemes)+    where+      encodeLexeme (token, positions) =+        TextBuilder.char '\''+          <> TextBuilder.text (escapeToken token)+          <> TextBuilder.char '\''+          <> if Vector.null positions+            then mempty+            else TextBuilder.char ':' <> TextBuilder.intercalateMap "," encodePosition (Vector.toList positions)+      encodePosition (pos, weight) =+        TextBuilder.string (show pos)+          <> case weight of+            AWeight -> TextBuilder.char 'A'+            BWeight -> TextBuilder.char 'B'+            CWeight -> TextBuilder.char 'C'+            DWeight -> mempty -- D is default, omitted by PostgreSQL+      escapeToken = Text.concatMap escapeChar+      escapeChar c = case c of+        '\'' -> "''"+        '\\' -> "\\\\"+        _ -> Text.singleton c++  textualDecoder = do+    -- Allow and ignore leading whitespace before the first lexeme+    Attoparsec.skipSpace+    lexemes <- lexemeParser `Attoparsec.sepBy` space1+    -- Allow and ignore trailing whitespace after the last lexeme+    Attoparsec.skipSpace+    -- Sort and deduplicate to match PostgreSQL's canonical form+    let Tsvector normalized = normalizeLexemes (map (\(t, ps) -> (t, Vector.fromList ps)) lexemes)+    pure (Tsvector normalized)+    where+      -- Consume one or more space / tab / newline characters between lexemes+      space1 = do+        _ <- Attoparsec.takeWhile1 (\c -> c == ' ' || c == '\t' || c == '\n')+        pure ()+      lexemeParser = do+        _ <- Attoparsec.char '\''+        token <- parseToken+        _ <- Attoparsec.char '\''+        positions <- parsePositions <|> pure []+        pure (token, positions)+      parseToken = do+        chars <- many (escapedQuote <|> escapedBackslash <|> normalChar)+        pure (Text.pack chars)+      escapedQuote = do+        _ <- Attoparsec.string "''"+        pure '\''+      escapedBackslash = do+        _ <- Attoparsec.string "\\\\"+        pure '\\'+      normalChar = Attoparsec.satisfy (\c -> c /= '\'' && c /= '\\')+      parsePositions = do+        _ <- Attoparsec.char ':'+        parsePosition `Attoparsec.sepBy1` Attoparsec.char ','+      parsePosition = do+        pos <- Attoparsec.decimal @Integer+        when (pos < 1 || pos > 16383) do+          fail ("tsvector position out of range 1..16383: " <> show pos)+        let pos' = fromIntegral pos :: Word16+        weight <-+          asum+            [ Attoparsec.char 'A' $> AWeight,+              Attoparsec.char 'B' $> BWeight,+              Attoparsec.char 'C' $> CWeight,+              Attoparsec.char 'D' $> DWeight,+              pure DWeight+            ]+        pure (pos', weight)++-- * Accessors++-- | Extract the tsvector as a list of (lexeme, positions) pairs.+-- Lexemes are in sorted order. Each position is a (position, weight) pair where position is 1-16383.+toLexemeList :: Tsvector -> [(Text, [(Word16, Weight)])]+toLexemeList (Tsvector lexemes) =+  map (\(t, ps) -> (t, Vector.toList ps)) (Vector.toList lexemes)++-- * Constructors++-- | Construct a tsvector from a list of (lexeme, positions) pairs with validation.+-- Returns 'Nothing' if any lexeme is empty, contains null characters,+-- or has positions outside the valid range 1..16383.+-- Sorts and deduplicates lexemes to match PostgreSQL's canonical representation.+refineFromLexemeList :: [(Text, [(Word16, Weight)])] -> Maybe Tsvector+refineFromLexemeList lexemes =+  if any (\(t, ps) -> Text.null t || Text.elem '\NUL' t || any (\(p, _) -> p < 1 || p > 16383) ps) lexemes+    then Nothing+    else Just (normalizeLexemes (map (\(t, ps) -> (t, Vector.fromList ps)) lexemes))++-- | Construct a tsvector from a list of (lexeme, positions) pairs.+-- Strips null characters from lexemes and removes empty lexemes.+-- Sorts and deduplicates lexemes to match PostgreSQL's canonical representation.+normalizeFromLexemeList :: [(Text, [(Word16, Weight)])] -> Tsvector+normalizeFromLexemeList lexemes =+  let cleaned = filter (\(t, _) -> not (Text.null t)) $ map (\(t, ps) -> (Text.replace "\NUL" "" t, ps)) lexemes+   in normalizeLexemes (map (\(t, ps) -> (t, Vector.fromList ps)) cleaned)
+ src/unit-tests/PostgresqlTypes/TsvectorSpec.hs view
@@ -0,0 +1,163 @@+module PostgresqlTypes.TsvectorSpec (spec) where++import Data.Data (Proxy (Proxy))+import qualified Data.Text as Text+import qualified PostgresqlTypes.Tsvector as Tsvector+import Test.Hspec+import Test.QuickCheck+import qualified UnitTests.Scripts as Scripts+import Prelude++spec :: Spec+spec = do+  describe "Show/Read laws" do+    Scripts.testShowRead (Proxy @Tsvector.Tsvector)++  describe "IsScalar laws" do+    Scripts.testIsScalar (Proxy @Tsvector.Tsvector)++  describe "Constructors" do+    describe "refineFromLexemeList" do+      it "creates Tsvector from valid lexemes" do+        let result = Tsvector.refineFromLexemeList [("hello", [(1, Tsvector.AWeight)]), ("world", [(2, Tsvector.BWeight)])]+        result `shouldSatisfy` (/= Nothing)++      it "rejects empty lexeme tokens" do+        Tsvector.refineFromLexemeList [("", [(1, Tsvector.AWeight)])] `shouldBe` Nothing++      it "rejects lexeme tokens with null characters" do+        Tsvector.refineFromLexemeList [("hel\NULlo", [(1, Tsvector.AWeight)])] `shouldBe` Nothing++      it "creates Tsvector from empty list" do+        let result = Tsvector.refineFromLexemeList []+        result `shouldSatisfy` (/= Nothing)+        fmap Tsvector.toLexemeList result `shouldBe` Just []++      it "sorts lexemes alphabetically" do+        let Just tsvec = Tsvector.refineFromLexemeList [("banana", []), ("apple", [])]+            lexemes = map fst (Tsvector.toLexemeList tsvec)+        lexemes `shouldBe` ["apple", "banana"]++      it "deduplicates lexemes" do+        let Just tsvec = Tsvector.refineFromLexemeList [("hello", [(1, Tsvector.AWeight)]), ("hello", [(2, Tsvector.BWeight)])]+            lexemes = map fst (Tsvector.toLexemeList tsvec)+        length lexemes `shouldBe` 1++    describe "normalizeFromLexemeList" do+      it "strips null characters from tokens" do+        let tsvec = Tsvector.normalizeFromLexemeList [("hel\NULlo", [(1, Tsvector.AWeight)])]+            lexemes = Tsvector.toLexemeList tsvec+        map fst lexemes `shouldBe` ["hello"]++      it "removes empty lexemes after stripping" do+        let tsvec = Tsvector.normalizeFromLexemeList [("\NUL", [(1, Tsvector.AWeight)])]+            lexemes = Tsvector.toLexemeList tsvec+        lexemes `shouldBe` []++      it "handles empty input" do+        let tsvec = Tsvector.normalizeFromLexemeList []+        Tsvector.toLexemeList tsvec `shouldBe` []++  describe "Accessors" do+    describe "toLexemeList" do+      it "extracts lexemes with positions" do+        let Just tsvec = Tsvector.refineFromLexemeList [("test", [(1, Tsvector.AWeight), (2, Tsvector.CWeight)])]+            [(token, positions)] = Tsvector.toLexemeList tsvec+        token `shouldBe` "test"+        length positions `shouldBe` 2++  describe "Weight" do+    it "has correct ordering" do+      Tsvector.AWeight `shouldSatisfy` (< Tsvector.BWeight)+      Tsvector.BWeight `shouldSatisfy` (< Tsvector.CWeight)+      Tsvector.CWeight `shouldSatisfy` (< Tsvector.DWeight)++    it "has correct Enum instances" do+      [Tsvector.AWeight ..] `shouldBe` [Tsvector.AWeight, Tsvector.BWeight, Tsvector.CWeight, Tsvector.DWeight]++  describe "Textual encoding" do+    it "encodes empty tsvector" do+      let Just tsvec = Tsvector.refineFromLexemeList []+       in show tsvec `shouldBe` "\"\""++    it "encodes lexeme without positions" do+      let Just tsvec = Tsvector.refineFromLexemeList [("hello", [])]+       in show tsvec `shouldBe` "\"'hello'\""++    it "encodes lexeme with single weighted position" do+      let Just tsvec = Tsvector.refineFromLexemeList [("hello", [(1, Tsvector.AWeight)])]+       in show tsvec `shouldBe` "\"'hello':1A\""++    it "encodes lexeme with default weight (D) by omitting it" do+      let Just tsvec = Tsvector.refineFromLexemeList [("hello", [(1, Tsvector.DWeight)])]+       in show tsvec `shouldBe` "\"'hello':1\""++    it "encodes multiple lexemes" do+      let Just tsvec = Tsvector.refineFromLexemeList [("apple", [(1, Tsvector.AWeight)]), ("banana", [(2, Tsvector.BWeight)])]+       in show tsvec `shouldBe` "\"'apple':1A 'banana':2B\""++    it "escapes single quotes in lexemes" do+      let Just tsvec = Tsvector.refineFromLexemeList [("it's", [(1, Tsvector.AWeight)])]+       in show tsvec `shouldBe` "\"'it''s':1A\""++    it "escapes backslashes in lexemes" do+      let Just tsvec = Tsvector.refineFromLexemeList [("back\\slash", [(1, Tsvector.AWeight)])]+       in show tsvec `shouldBe` "\"'back\\\\\\\\slash':1A\""++  describe "Textual decoding" do+    it "decodes empty string" do+      Tsvector.toLexemeList (read "\"\"") `shouldBe` []++    it "decodes lexeme without positions" do+      let tsvec = read "\"'hello'\"" :: Tsvector.Tsvector+          [(token, positions)] = Tsvector.toLexemeList tsvec+      token `shouldBe` "hello"+      positions `shouldBe` []++    it "decodes lexeme with weighted position" do+      let tsvec = read "\"'hello':1A\"" :: Tsvector.Tsvector+          [(token, positions)] = Tsvector.toLexemeList tsvec+      token `shouldBe` "hello"+      positions `shouldBe` [(1, Tsvector.AWeight)]++    it "decodes lexeme with default weight" do+      let tsvec = read "\"'hello':1\"" :: Tsvector.Tsvector+          [(_, positions)] = Tsvector.toLexemeList tsvec+      positions `shouldBe` [(1, Tsvector.DWeight)]++    it "decodes multiple positions" do+      let tsvec = read "\"'hello':1A,2B,3C\"" :: Tsvector.Tsvector+          [(_, positions)] = Tsvector.toLexemeList tsvec+      length positions `shouldBe` 3++    it "decodes escaped single quotes" do+      let tsvec = read "\"'it''s':1A\"" :: Tsvector.Tsvector+          [(token, _)] = Tsvector.toLexemeList tsvec+      token `shouldBe` "it's"++    it "decodes escaped backslashes" do+      let tsvec = read "\"'back\\\\\\\\slash':1A\"" :: Tsvector.Tsvector+          [(token, _)] = Tsvector.toLexemeList tsvec+      token `shouldBe` "back\\slash"++  describe "Property Tests" do+    it "roundtrips through toLexemeList and refineFromLexemeList" do+      property \(tsvec :: Tsvector.Tsvector) ->+        let lexemes = Tsvector.toLexemeList tsvec+            reconstructed = Tsvector.refineFromLexemeList lexemes+         in reconstructed === Just tsvec++    it "refineFromLexemeList is idempotent via normalization" do+      property \(tsvec :: Tsvector.Tsvector) ->+        let lexemes = Tsvector.toLexemeList tsvec+            Just tsvec' = Tsvector.refineFromLexemeList lexemes+            lexemes' = Tsvector.toLexemeList tsvec'+            Just tsvec'' = Tsvector.refineFromLexemeList lexemes'+         in tsvec' === tsvec''++    it "normalizeFromLexemeList produces same result as refineFromLexemeList for valid input" do+      property \(tsvec :: Tsvector.Tsvector) ->+        let lexemes = Tsvector.toLexemeList tsvec+            normalized = Tsvector.normalizeFromLexemeList lexemes+            Just refined = Tsvector.refineFromLexemeList lexemes+         in normalized === refined