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 +1/−4
- postgresql-types.cabal +3/−1
- src/integration-tests/Main.hs +3/−0
- src/library/PostgresqlTypes.hs +4/−0
- src/library/PostgresqlTypes/Multirange/QuickCheckGen.hs +2/−3
- src/library/PostgresqlTypes/Tsvector.hs +289/−0
- src/unit-tests/PostgresqlTypes/TsvectorSpec.hs +163/−0
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