Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions persistent-postgresql-ng/Database/Persist/Postgresql/CustomType.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module : Database.Persist.Postgresql.CustomType
-- Description : Guide for writing custom PersistField instances with the binary protocol backend
--
-- = Overview
--
-- The pipeline backend uses PostgreSQL's binary wire protocol instead of the
-- text protocol used by @persistent-postgresql@ (via @postgresql-simple@). This
-- is faster — no parsing/rendering of text representations — but it changes
-- how some custom types need to be encoded.
--
-- Most custom @PersistField@ instances work without modification. This module
-- documents the cases where you may need to adjust your approach.
--
-- = How PersistValue maps to PostgreSQL
--
-- The binary backend encodes each @PersistValue@ with a specific PostgreSQL
-- OID (type identifier) and binary representation:
--
-- +------------------------+------------+-------------+-----------------------------------+
-- | PersistValue | PG Type | OID | Notes |
-- +========================+============+=============+===================================+
-- | @PersistText@ | text | 25 | Binary UTF-8 |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistByteString@ | bytea | 17 | Raw binary |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistInt64@ | int8 | 20 | 8-byte big-endian |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistDouble@ | float8 | 701 | IEEE 754 double |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistRational@ | numeric | 1700 | Arbitrary precision |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistBool@ | bool | 16 | Single byte |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistDay@ | date | 1082 | Days since 2000-01-01 |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistTimeOfDay@ | time | 1083 | Microseconds since midnight |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistUTCTime@ | timestamptz| 1184 | Microseconds since 2000-01-01 UTC |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistList@ | (unknown) | 0 | JSON text, PG infers type |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistArray@ | \<type\>[] | varies | Native PostgreSQL array |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistMap@ | (unknown) | 0 | JSON text, PG infers type |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistLiteral_ Escaped@ | (unknown) | 0 | Text format, PG infers type |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistLiteral_ DbSpecific@| (unknown) | 0 | Text format, PG infers type |
-- +------------------------+------------+-------------+-----------------------------------+
-- | @PersistLiteral_ Unescaped@ | — | — | Inlined into SQL text |
-- +------------------------+------------+-------------+-----------------------------------+
--
-- = Custom Types That Just Work
--
-- If your custom type stores as one of the standard @PersistValue@ constructors,
-- it will work without any changes:
--
-- @
-- newtype Email = Email Text
--
-- instance PersistField Email where
-- toPersistValue (Email t) = PersistText t
-- fromPersistValue (PersistText t) = Right (Email t)
-- fromPersistValue _ = Left "Expected PersistText for Email"
--
-- instance PersistFieldSql Email where
-- sqlType _ = SqlString
-- @
--
-- This covers the vast majority of custom types: newtypes over @Text@, @Int@,
-- @Double@, @Bool@, @Day@, @UTCTime@, @ByteString@, etc.
--
-- = Types Requiring OID 0 (Type Inference)
--
-- When PostgreSQL's column type doesn't match any built-in @PersistValue@ OID,
-- you need the value to be sent with OID 0 so PostgreSQL infers the type from
-- the column. Use @PersistLiteral_ Escaped@ or @PersistLiteral_ DbSpecific@:
--
-- @
-- newtype UUID = UUID Text
--
-- instance PersistField UUID where
-- toPersistValue (UUID t) = PersistLiteral_ Escaped (encodeUtf8 t)
-- fromPersistValue (PersistLiteral_ Escaped bs) = Right (UUID (decodeUtf8 bs))
-- fromPersistValue _ = Left "Expected PersistLiteral_ Escaped for UUID"
--
-- instance PersistFieldSql UUID where
-- sqlType _ = SqlOther "UUID"
-- @
--
-- The bytes in @PersistLiteral_ Escaped@ are sent as-is with OID 0 in text
-- format. PostgreSQL sees the text representation and casts it to the column
-- type (UUID in this case). This is the same behavior as @postgresql-simple@'s
-- @Unknown@ type.
--
-- __Other types that use this pattern:__ @INET@, @CIDR@, @MACADDR@, @LTREE@,
-- @HSTORE@, custom enum types, PostGIS geometry types.
--
-- = Types Requiring SQL Inlining
--
-- Some PostgreSQL types cannot accept /any/ parameterized input — even with
-- OID 0, PostgreSQL won't cast. For these, use @PersistLiteral_ Unescaped@
-- to inline the value directly into the SQL text:
--
-- @
-- newtype PgInterval = PgInterval NominalDiffTime
--
-- instance PersistField PgInterval where
-- toPersistValue (PgInterval ndt) =
-- let (sci, _) = fromRationalRepetendUnlimited (toRational ndt)
-- s = formatScientific Fixed Nothing sci
-- in PersistLiteral_ Unescaped (encodeUtf8 ("'" <> pack s <> " seconds'::interval"))
-- fromPersistValue (PersistRational r) = Right (PgInterval (fromRational r))
-- fromPersistValue _ = Left "Expected PersistRational for PgInterval"
-- @
--
-- __Important:__ @PersistLiteral_ Unescaped@ is inlined into the SQL string
-- /before/ parameter encoding. The value must be a valid SQL expression. Always
-- use explicit casts (e.g. @'...'::interval@) and ensure the content is safe
-- (no user-controlled input without validation).
--
-- = JSON and JSONB Columns
--
-- Aeson's @Value@ type is stored as @PersistLiteral_ Escaped@ with the JSON
-- bytes. The binary backend sends this with OID 0, so PostgreSQL infers the
-- @jsonb@ type from the column.
--
-- If you have a custom type that serializes to JSON for a @jsonb@ column:
--
-- @
-- import Data.Aeson (ToJSON, FromJSON, encode, eitherDecodeStrict)
-- import qualified Data.ByteString.Lazy as BSL
--
-- instance PersistField MyJsonType where
-- toPersistValue = PersistLiteralEscaped . BSL.toStrict . encode
-- fromPersistValue (PersistByteString bs) =
-- case eitherDecodeStrict bs of
-- Right v -> Right v
-- Left err -> Left (pack err)
-- fromPersistValue _ = Left "Expected PersistByteString for MyJsonType"
--
-- instance PersistFieldSql MyJsonType where
-- sqlType _ = SqlOther "jsonb"
-- @
--
-- The @PersistLiteralEscaped@ (= @PersistLiteral_ Escaped@) encoding sends
-- with OID 0, so PostgreSQL accepts the JSON text for a @jsonb@ column.
--
-- = PersistList vs PersistArray
--
-- These two constructors serve different purposes in this backend:
--
-- [@PersistList@] Encodes as __JSON text__ with OID 0 (unknown). PostgreSQL
-- infers the column type, so this works for both @VARCHAR@ and @jsonb@
-- columns. Used by persistent internally for embedded entities. If your
-- custom type uses @PersistList@, it will be stored as a JSON text array
-- like @[1,2,3]@.
--
-- [@PersistArray@] Encodes as a __native PostgreSQL array__ (e.g. @int8[]@,
-- @text[]@). The element type is inferred from the first non-null element.
-- This is used by the @IN \u2192 ANY@ rewriting: @WHERE id IN (?,?,?)@
-- becomes @WHERE id = ANY($1)@ with a single @PersistArray@ parameter.
--
-- If you want your custom list type to use native arrays:
--
-- @
-- newtype IntList = IntList [Int64]
--
-- instance PersistField IntList where
-- toPersistValue (IntList xs) = PersistArray (map PersistInt64 xs)
-- fromPersistValue (PersistList xs) = IntList \<$\> mapM fromPersistValue xs
-- fromPersistValue _ = Left "Expected PersistList for IntList"
--
-- instance PersistFieldSql IntList where
-- sqlType _ = SqlOther "int8[]"
-- @
--
-- = PostgreSQL Enum Types
--
-- Custom PostgreSQL enums work with @PersistLiteral_ Escaped@ or @PersistText@:
--
-- @
-- data Color = Red | Green | Blue
--
-- instance PersistField Color where
-- toPersistValue Red = PersistLiteral_ Escaped "red"
-- toPersistValue Green = PersistLiteral_ Escaped "green"
-- toPersistValue Blue = PersistLiteral_ Escaped "blue"
-- fromPersistValue (PersistLiteral_ Escaped "red") = Right Red
-- fromPersistValue (PersistLiteral_ Escaped "green") = Right Green
-- fromPersistValue (PersistLiteral_ Escaped "blue") = Right Blue
-- fromPersistValue _ = Left "Invalid Color"
--
-- instance PersistFieldSql Color where
-- sqlType _ = SqlOther "color" -- must match CREATE TYPE color AS ENUM (...)
-- @
--
-- Using @PersistLiteral_ Escaped@ sends with OID 0, letting PostgreSQL match
-- the text @\"red\"@ against the enum type.
--
-- = When Do You Need Explicit SQL Casts?
--
-- The short answer: __almost never__ with standard persistent operations.
--
-- Standard persistent operations (@insert@, @update@, @selectList@,
-- @deleteWhere@, etc.) generate SQL like @INSERT INTO "table" ("col") VALUES (?)@
-- or @UPDATE "table" SET "col" = ? WHERE ...@. In these contexts, PostgreSQL
-- knows the column type from the table definition and will infer the parameter
-- type from OID 0. This covers:
--
-- * Custom enums via @PersistLiteral_ Escaped@
-- * UUIDs via @PersistLiteral_ Escaped@
-- * JSONB via @PersistLiteral_ Escaped@ or @PersistList@/@PersistMap@
-- * All standard @PersistValue@ types
--
-- You __do__ need explicit casts (@::type@) in these cases:
--
-- * __@rawSql@ / @rawExecute@__ where PostgreSQL can't infer the type from
-- context. For example, @SELECT ? + 1@ is ambiguous — PostgreSQL doesn't
-- know if @?@ is @int4@, @int8@, @numeric@, etc. Write
-- @SELECT ?::int8 + 1@ instead.
--
-- * __Function arguments__ where PostgreSQL has multiple overloads. For example,
-- @jsonb_build_object('key', ?)@ may need @?::text@ if PostgreSQL can't
-- resolve the overload.
--
-- * __@PersistLiteral_ Unescaped@__ values are always inlined into SQL and
-- should include their own cast (e.g. @'5 seconds'::interval@).
--
-- = Unsupported Operations
--
-- The following PostgreSQL features are __incompatible with pipeline mode__ and
-- will not work with this backend:
--
-- * __COPY__ (@COPY FROM@ / @COPY TO@) — uses a separate sub-protocol that
-- conflicts with pipeline mode.
--
-- * __LISTEN/NOTIFY__ — asynchronous notifications require consuming results
-- outside the pipeline flow, which conflicts with the pending result counter.
--
-- * __Large Objects__ — large object operations use a separate API that is not
-- supported in pipeline mode.
--
-- These operations are also uncommon in persistent-based applications. If you
-- need them, use the raw @LibPQ.Connection@ via @getPipelineConn@ and manage
-- the pipeline state yourself.
--
-- = Migration from persistent-postgresql
--
-- When migrating from @persistent-postgresql@ to @persistent-postgresql-ng@:
--
-- 1. __Most types work unchanged.__ Standard @PersistField@ instances using
-- @PersistText@, @PersistInt64@, @PersistBool@, etc. need no changes.
--
-- 2. __Types using @PersistRational@ for non-numeric columns__ (like
-- @interval@) need to switch to @PersistLiteral_ Unescaped@ with an
-- explicit cast. In the text protocol, PostgreSQL would auto-cast a text
-- number to @interval@; the binary protocol sends with OID 1700 (numeric)
-- which PostgreSQL won't cast.
--
-- 3. __UUID types__ work if they use @PersistLiteral_ Escaped@ with the
-- hex text representation. The backend sends with OID 0 and decodes
-- binary UUIDs to hex text on the way back.
--
-- 4. __Array columns__ can now use @PersistArray@ for native PostgreSQL
-- arrays instead of JSON-encoded text. The @IN@ operator automatically
-- uses native arrays via the @= ANY(...)@ rewriting.
--
-- 5. __JSONB__ values stored via @PersistLiteral_ Escaped@ (the standard
-- pattern from the JSON module) work correctly — OID 0 lets PostgreSQL
-- accept the JSON text for @jsonb@ columns.
module Database.Persist.Postgresql.CustomType () where
70 changes: 70 additions & 0 deletions persistent-postgresql-ng/Database/Persist/Postgresql/Internal.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Shared internal utilities for the pipeline backend.
--
-- Contains escape functions, the 'PgInterval' type, and re-exports from the
-- migration module.
module Database.Persist.Postgresql.Internal
( PgInterval (..)
, AlterDB (..)
, AlterTable (..)
, AlterColumn (..)
, SafeToRemove
, migrateStructured
, migrateEntitiesStructured
, mockMigrateStructured
, addTable
, findAlters
, maySerial
, mayDefault
, showSqlType
, showColumn
, showAlter
, showAlterDb
, showAlterTable
, getAddReference
, udToPair
, safeToRemove
, postgresMkColumns
, getAlters
, escapeE
, escapeF
, escape
) where

import Data.Scientific (FPFormat (Fixed), formatScientific, fromRationalRepetendUnlimited)
import qualified Data.Text
import qualified Data.Text.Encoding
import Data.Time (NominalDiffTime)
import Database.Persist.Sql
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restyled by fourmolu

Suggested change
import Database.Persist.Sql

import Database.Persist.Postgresql.Internal.Migration

-- | Represent Postgres interval using NominalDiffTime.
--
-- Note that this type cannot be losslessly round tripped through PostgreSQL.
-- For example the value @'PgInterval' 0.0000009@ will truncate extra
-- precision. And the value @'PgInterval' 9223372036854.775808@ will overflow.
newtype PgInterval = PgInterval {getPgInterval :: NominalDiffTime}
deriving (Eq, Show)

instance PersistField PgInterval where
toPersistValue (PgInterval ndt) =
-- Inline the interval literal into the SQL text because binary-protocol
-- numeric (PersistRational) can't be implicitly cast to interval, and
-- text with OID 25 also can't. Unescaped literals get inlined before
-- parameter encoding.
let r = toRational ndt
-- Format as a quoted interval literal: '123.456000 seconds'::interval
-- Use Scientific for exact decimal representation (no Double precision loss)
(sci, _) = fromRationalRepetendUnlimited r
s = formatScientific Fixed Nothing sci
in PersistLiteral_ Unescaped (Data.Text.Encoding.encodeUtf8
(Data.Text.pack ("'" <> s <> " seconds'::interval")))
fromPersistValue (PersistRational r) =
Right $ PgInterval (fromRational r)
fromPersistValue x =
Left $ "PgInterval: expected PersistRational, got: " <> Data.Text.pack (show x)

instance PersistFieldSql PgInterval where
sqlType _ = SqlOther "interval"
Loading
Loading