From fbb1f3b8a5d67f00189f52f398ad0717d028cb63 Mon Sep 17 00:00:00 2001 From: Orion Kindel Date: Fri, 14 Jun 2024 16:09:59 -0500 Subject: [PATCH] fix: add interval support --- bun.lockb | Bin 9248 -> 11061 bytes package.json | 2 + src/Data.Postgres.Interval.js | 30 +++++++++ src/Data.Postgres.Interval.purs | 88 ++++++++++++++++++++++++++ src/Data.Postgres.js | 15 +++++ src/Data.Postgres.purs | 72 ++++++++++++++++++++- test/Test.Data.Postgres.Interval.purs | 37 +++++++++++ test/Test.Data.Postgres.purs | 52 ++++++++++++--- test/Test.Main.purs | 2 + 9 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 src/Data.Postgres.Interval.js create mode 100644 src/Data.Postgres.Interval.purs create mode 100644 test/Test.Data.Postgres.Interval.purs diff --git a/bun.lockb b/bun.lockb index 9e01eec4014c4363cafe55ad33f8e482eb561a35..01606f753385531ecd1cd0f6234857f0c23d9f9f 100755 GIT binary patch delta 2598 zcmcIm2~1Q+7@m2sEDJnfMP&~b5K#fyl~eHOgCZh`h^fWZ2JzyM3bMjN@q$&UT5G)i z)ItkHEmlcG74bf+wOSWi5k+eoi+8l7*1Kw2>_3lvt)@+EnskzR|NPhd-^{%E|JjhZ zYr+~s;CYMU*nqrkm5xJ9cdY}{JJ$q!a%l0hOOveib4N^>=hwD5h=@(M*61@VP}j{6 zVk#~yV&hcou7p?wq)Mcv=B(M7IF7`wK;D#t18g_f*87y-;|SqVcnv9YYPV5Sjy`v` zIhwRlLYze>y{Vuej~qt6C-RL*nO~iaPPI`ZQWhjLYkDz@lP^Ldq(6?`P+l;-IGY9c z!tO)}44{Fk!nkG&Dax21@!jelAD>O=rx`Wq_>SA(PlKs604ix>i049u|DxH!p$Rq1 zPj_ATV%Dl9B;tz3dZKRU@%CL8Rwq`wl=%NlOFm2eX3yp0Rl&7zna-q>A&HAoY{WRD zF)oEOc2Qg>ffe=wuS1YSnGlt7pxi)+(^Z_K<#c+`vtgsWfTn?;uf}#d@ zB^n(#ah5I=+6y!m6tWmag&iUBqDm=?qF=*%vKX$*4y-bP4hMxp3@wBthZxy0dqU!X zmkm?c%Wxz)xFpy(L_*ea!3>CUIm#q>Om2@52Z)+us0l+~oQz;xB9k%Ir!M@7-hV#jeiwoX8*j< z?)c)0|L2v8Q2(~jL{Thm#h(WMX{9h%(E<%F$x)|Y>6F(+o!`*;=d$$D?-U-^( z%#S|Te6GcF{Mx!lw@SuuykLCf0Qbx_dkQzzew#hDY)NqJQqRx{+SIh-`RAjT1}u*3 z+<|~7>2m8rJTkcLWQ0m*9#Wi>sT``DjgaWV!|%u`APECLL2fdiOr4>gH^Nq)2i`TA z@-WHO2oqg-IE0)VDBO(T=*C07TQXI`KIHZyr*ltE@vv16g>)t)+aP8vs)Q!I#*{r-8*#597O|?G$e&4`} zr%Pb6yQ+MKUxnw9KzG;eM?->!BrNmo=zQzL2QBZfT-H{@c}6w(wLU+1J>JMIvc$S) zH@G$@pQ?^tlKfg?8&_3#qPkZ`^9#%G^&+@WFKHu^wv>hqG9NNFcFg46?l#zGrbj%X zBR5AvYS5#7C!gFO);?055%*@y*k4A>s{hzE^@B|wGw)Y-OtfAoE?Wh>QZ;6KuG`5Q zGr}%fZhiImjwQOMGCp`*RZhj;j4extn6`B`kAF7!*Tbo2PHkErGve&Pehrm7SDrcY zQE~k7LkA*0xmoy|J^)$ub8P58{HIT|AZ{)YvuHwb)q(VRvDi?LxG&5dF(pRHkNT%$y|^X3DN6 zCSvik)}t5*v&W3VB_F!--3DitY?W`r7|3?YOF=+5e`RL(dWY6xQrk_rno>T;L*F;UIA(RlQoJy5Xj22>uAY>}uM^=EP+K8GELnxa~s_!>OTs@q&tVa)b znvb?lxO73RQ?!@R01wErq<#NSy{GtFds5-GaKsi$Zo(pM z3T=neTJ>P*jgZvprAVKDzKo+~7?6dOUJ*CO-l=b^y+Ujngeeg4_ENz(FO48Q1*EFY z(J%yz<%~#=0;ztBs}xmh{7{Xp)ep>G@vz$~Omh~G5q~I4MRa5fEp_c8eY;I+f`HSd zt8?^uxkdQmF%=Yg=)Mw@An$bVXQ0lv2fp4^-6gGP6n&Q0Qra16wO7k7X^?At>XMKs+<1WIS zKK`oiS-XRFk1;47CEU~bD8)Yjbt2VA7{Wp6(?^kCgrfW+eHiGx`$3D&+*IvXLH_^; CC0zdi delta 1509 zcmbVMZ){Ul6uFMvB zd(Qp6d(S=RpFESDPBeG^Ui@xu!g2A^=|=B)rC50+A9(l)_tHBB?T@1yetPk-={Pea za7>T1rvod1vBFrfME^Tb-Hc5(qV=F1d3J0U`3pnz%UJ%{xk&GnV9bZXiP6Em&!cl6 zVg>OYw3OU$+QQJfJ$pyftd}!Zg?_|^k&!&>MqGn9g_iPKObc0_^qzK&H!Sn<54T?a z`1E4`PcY}L&Eua?sOfo+RX@?X;(pyl_XX~q8+UFfTthk@!Gx4{S1I$UxouujQw z3HB>GOehNcfT$9h-8wY66*%I~aT!#X4p$Jp4TO-TiAt=otK{pPBKn3PQ=t)a2yt6+hJRL t7&@EB%Eue>+*cSWjSdwDOEfeGi{m@ I} */ +export const make = o => Object.assign(PostgresInterval(''), o) + +/** @type {(s: string) => () => I} */ +export const parse = s => () => PostgresInterval(s) + +/** @type {(a: I) => number} */ +export const getYears = i => i.years || 0.0 + +/** @type {(a: I) => number} */ +export const getMonths = i => i.months || 0.0 + +/** @type {(a: I) => number} */ +export const getDays = i => i.days || 0.0 + +/** @type {(a: I) => number} */ +export const getMinutes = i => i.minutes || 0.0 + +/** @type {(a: I) => number} */ +export const getHours = i => i.hours || 0.0 + +/** @type {(a: I) => number} */ +export const getSeconds = i => i.seconds || 0.0 + +/** @type {(a: I) => number} */ +export const getMilliseconds = i => i.milliseconds || 0.0 diff --git a/src/Data.Postgres.Interval.purs b/src/Data.Postgres.Interval.purs new file mode 100644 index 0000000..f467c79 --- /dev/null +++ b/src/Data.Postgres.Interval.purs @@ -0,0 +1,88 @@ +module Data.Postgres.Interval where + +import Prelude + +import Data.Int as Int +import Data.Maybe (Maybe(..)) +import Data.Newtype (unwrap) +import Data.Time.Duration (class Duration, Days(..), Hours(..), Milliseconds(..), Minutes(..), Seconds(..), convertDuration) +import Effect (Effect) + +zero :: IntervalRecord +zero = {years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0.0} + +type IntervalRecord = + { years :: Int + , months :: Int + , days :: Int + , hours :: Int + , minutes :: Int + , seconds :: Int + , milliseconds :: Number + } + +foreign import data Interval :: Type + +foreign import make :: IntervalRecord -> Interval +foreign import parse :: String -> Effect Interval + +foreign import getYears :: Interval -> Int +foreign import getMonths :: Interval -> Int +foreign import getDays :: Interval -> Int +foreign import getHours :: Interval -> Int +foreign import getMinutes :: Interval -> Int +foreign import getSeconds :: Interval -> Int +foreign import getMilliseconds :: Interval -> Number + +toDuration :: forall d. Semigroup d => Duration d => Interval -> Maybe d +toDuration a = + let + includesMonths = getYears a > 0 || getMonths a > 0 + + days :: d + days = convertDuration $ Days $ Int.toNumber $ getDays a + + hours :: d + hours = convertDuration $ Hours $ Int.toNumber $ getHours a + + minutes :: d + minutes = convertDuration $ Minutes $ Int.toNumber $ getMinutes a + + seconds :: d + seconds = convertDuration $ Seconds $ Int.toNumber $ getSeconds a + + milliseconds :: d + milliseconds = convertDuration $ Milliseconds $ getMilliseconds a + in + if includesMonths then Nothing else Just (days <> hours <> minutes <> seconds <> milliseconds) + +toRecord :: Interval -> IntervalRecord +toRecord a = + { years: getYears a + , months: getMonths a + , days: getDays a + , hours: getHours a + , minutes: getMinutes a + , seconds: getSeconds a + , milliseconds: getMilliseconds a + } + +fromDuration :: forall d. Duration d => d -> Interval +fromDuration a = + let + millisTotal :: Number + millisTotal = (unwrap :: Milliseconds -> Number) $ convertDuration a + secondFactor = 1000.0 + minuteFactor = 60.0 * secondFactor + hourFactor = 60.0 * minuteFactor + dayFactor = 24.0 * hourFactor + days = Int.trunc $ millisTotal / dayFactor + daysRem = millisTotal - (Int.toNumber days * dayFactor) + hours = Int.trunc $ daysRem / hourFactor + hoursRem = daysRem - (Int.toNumber hours * hourFactor) + minutes = Int.trunc $ hoursRem / minuteFactor + minutesRem = hoursRem - (Int.toNumber minutes * minuteFactor) + seconds = Int.trunc $ minutesRem / secondFactor + milliseconds = minutesRem - (Int.toNumber seconds * secondFactor) + in + make {years: 0, months: 0, days, hours, minutes, seconds, milliseconds} diff --git a/src/Data.Postgres.js b/src/Data.Postgres.js index 2d63f3a..bc022c3 100644 --- a/src/Data.Postgres.js +++ b/src/Data.Postgres.js @@ -1,10 +1,25 @@ import Pg from 'pg' import Range from 'postgres-range' import { Buffer } from 'buffer' +import PostgresInterval from 'postgres-interval' /** @type {(a: unknown) => boolean} */ export const isInstanceOfBuffer = a => a instanceof Buffer +/** @type {(a: unknown) => boolean} */ +export const isInstanceOfInterval = a => { + return typeof a === 'object' + && a !== null + && ('years' in a + || 'months' in a + || 'days' in a + || 'hours' in a + || 'minutes' in a + || 'seconds' in a + || 'milliseconds' in a + ) +} + export const modifyPgTypes = () => { // https://github.com/brianc/node-pg-types/blob/master/lib/textParsers.js const oid = { diff --git a/src/Data.Postgres.purs b/src/Data.Postgres.purs index 2a3e439..484cc1f 100644 --- a/src/Data.Postgres.purs +++ b/src/Data.Postgres.purs @@ -9,13 +9,18 @@ import Control.Monad.Morph (hoist) import Control.Monad.Trans.Class (lift) import Data.Bifunctor (lmap) import Data.DateTime (DateTime) +import Data.DateTime.Instant (Instant) +import Data.DateTime.Instant as Instant import Data.List.NonEmpty (NonEmptyList) import Data.Maybe (Maybe(..)) import Data.Newtype (class Newtype, unwrap, wrap) +import Data.Postgres.Interval (Interval) +import Data.Postgres.Interval as Interval import Data.Postgres.Range (Range, __rangeFromRecord, __rangeRawFromRaw, __rangeRawFromRecord, __rangeRawToRecord, __rangeToRecord) import Data.Postgres.Raw (Null(..), Raw, jsNull) import Data.Postgres.Raw (unsafeFromForeign, asForeign) as Raw import Data.RFC3339String as DateTime.ISO +import Data.Time.Duration (Days, Hours, Milliseconds, Minutes, Seconds) import Data.Traversable (traverse) import Effect (Effect) import Effect.Exception (error) @@ -41,6 +46,7 @@ derive newtype instance ReadForeign a => ReadForeign (JSON a) foreign import modifyPgTypes :: Effect Unit foreign import isInstanceOfBuffer :: F.Foreign -> Boolean +foreign import isInstanceOfInterval :: F.Foreign -> Boolean -- | The serialization & deserialization monad. type RepT = ExceptT (NonEmptyList ForeignError) Effect @@ -70,6 +76,9 @@ instance (Serialize a, Deserialize a) => Rep a unsafeSerializeCoerce :: forall m a. Monad m => a -> m Raw unsafeSerializeCoerce = pure <<< Raw.unsafeFromForeign <<< F.unsafeToForeign +invalidDuration :: NonEmptyList ForeignError +invalidDuration = pure $ ForeignError $ "Can't convert interval with year or month components to Milliseconds" + instance Serialize Raw where serialize = pure @@ -109,10 +118,38 @@ instance Serialize String where instance Serialize Number where serialize = unsafeSerializeCoerce --- | `timestamp`, `timestamptz` +-- | `interval` instance Serialize DateTime where serialize = serialize <<< unwrap <<< DateTime.ISO.fromDateTime +-- | `interval` +instance Serialize Interval where + serialize = unsafeSerializeCoerce + +-- | `interval` +instance Serialize Milliseconds where + serialize = serialize <<< Interval.fromDuration + +-- | `interval` +instance Serialize Seconds where + serialize = serialize <<< Interval.fromDuration + +-- | `interval` +instance Serialize Minutes where + serialize = serialize <<< Interval.fromDuration + +-- | `interval` +instance Serialize Hours where + serialize = serialize <<< Interval.fromDuration + +-- | `interval` +instance Serialize Days where + serialize = serialize <<< Interval.fromDuration + +-- | `timestamp`, `timestamptz` +instance Serialize Instant where + serialize = serialize <<< Instant.toDateTime + -- | `Just` -> `a`, `Nothing` -> `NULL` instance Serialize a => Serialize (Maybe a) where serialize (Just a) = serialize a @@ -151,6 +188,35 @@ instance Deserialize Buffer where in readBuffer <<< Raw.asForeign +-- | `interval` +instance Deserialize Interval where + deserialize = + let + notInterval a = pure $ TypeMismatch (tagOf a) "Interval" + readInterval a = when (not $ isInstanceOfInterval a) (throwError $ notInterval a) $> unsafeFromForeign a + in + readInterval <<< Raw.asForeign + +-- | `interval` +instance Deserialize Milliseconds where + deserialize = flip bind (liftMaybe invalidDuration) <<< map Interval.toDuration <<< deserialize + +-- | `interval` +instance Deserialize Seconds where + deserialize = flip bind (liftMaybe invalidDuration) <<< map Interval.toDuration <<< deserialize + +-- | `interval` +instance Deserialize Minutes where + deserialize = flip bind (liftMaybe invalidDuration) <<< map Interval.toDuration <<< deserialize + +-- | `interval` +instance Deserialize Hours where + deserialize = flip bind (liftMaybe invalidDuration) <<< map Interval.toDuration <<< deserialize + +-- | `interval` +instance Deserialize Days where + deserialize = flip bind (liftMaybe invalidDuration) <<< map Interval.toDuration <<< deserialize + -- | `int2`, `int4` instance Deserialize Int where deserialize = F.readInt <<< Raw.asForeign @@ -183,6 +249,10 @@ instance Deserialize DateTime where let invalid = pure $ ForeignError $ "Not a valid ISO8601 string: `" <> s <> "`" liftMaybe invalid $ DateTime.ISO.toDateTime $ wrap s +-- | `timestamp`, `timestamptz` +instance Deserialize Instant where + deserialize = map Instant.fromDateTime <<< deserialize + -- | postgres `array` instance Deserialize a => Deserialize (Array a) where deserialize = traverse (deserialize <<< Raw.unsafeFromForeign) <=< F.readArray <<< Raw.asForeign diff --git a/test/Test.Data.Postgres.Interval.purs b/test/Test.Data.Postgres.Interval.purs new file mode 100644 index 0000000..062a38d --- /dev/null +++ b/test/Test.Data.Postgres.Interval.purs @@ -0,0 +1,37 @@ +module Test.Data.Postgres.Interval where + +import Prelude + +import Data.Postgres.Interval as Interval +import Data.Time.Duration (Milliseconds(..)) +import Data.Traversable (for_) +import Data.Tuple.Nested (type (/\), (/\)) +import Effect.Class (liftEffect) +import Test.Spec (Spec, describe, it) +import Test.Spec.Assertions (shouldEqual) + +spec :: Spec Unit +spec = + describe "Data.Postgres.Interval" do + it "parse & toRecord" do + p <- liftEffect $ Interval.parse "3 days 04:05:06" + Interval.toRecord p `shouldEqual` Interval.zero {days = 3, hours = 4, minutes = 5, seconds = 6} + + it "make & toRecord" do + let p = Interval.make $ Interval.zero {days = 3, hours = 4, minutes = 5, seconds = 6} + Interval.toRecord p `shouldEqual` Interval.zero {days = 3, hours = 4, minutes = 5, seconds = 6} + + describe "fromDuration" do + for_ + [ Milliseconds 100.0 /\ Interval.zero {milliseconds = 100.0} + , Milliseconds 1000.0 /\ Interval.zero {seconds = 1} + , Milliseconds 1100.0 /\ Interval.zero {seconds = 1, milliseconds = 100.0} + , Milliseconds 60000.0 /\ Interval.zero {minutes = 1} + , Milliseconds 61100.0 /\ Interval.zero {minutes = 1, seconds = 1, milliseconds = 100.0} + , Milliseconds 3600000.0 /\ Interval.zero {hours = 1} + , Milliseconds 3661100.0 /\ Interval.zero {hours = 1, minutes = 1, seconds = 1, milliseconds = 100.0} + , Milliseconds 86400000.0 /\ Interval.zero {days = 1} + , Milliseconds 90061100.0 /\ Interval.zero {days = 1, hours = 1, minutes = 1, seconds = 1, milliseconds = 100.0} + ] + \(i /\ o) -> it ("converts " <> show i) do + Interval.toRecord (Interval.fromDuration i) `shouldEqual` o diff --git a/test/Test.Data.Postgres.purs b/test/Test.Data.Postgres.purs index 74ec525..06c7551 100644 --- a/test/Test.Data.Postgres.purs +++ b/test/Test.Data.Postgres.purs @@ -2,28 +2,28 @@ module Test.Data.Postgres where import Prelude -import Control.Monad.Gen (chooseInt, elements, oneOf) +import Control.Monad.Gen (chooseFloat, chooseInt, elements, oneOf) import Control.Parallel (parTraverse_) import Data.Array (intercalate) import Data.Array as Array import Data.Array.NonEmpty as Array.NonEmpty import Data.DateTime (DateTime(..), canonicalDate) -import Data.DateTime.Instant as Instant import Data.Enum (toEnum) import Data.Foldable (fold) import Data.Identity (Identity) -import Data.Int as Int -import Data.Maybe (Maybe(..), fromJust, maybe) +import Data.Maybe (Maybe(..), fromJust, fromMaybe) import Data.Newtype (class Newtype, unwrap, wrap) import Data.Number (abs) as Number import Data.Postgres (class Rep) +import Data.Postgres.Interval (Interval) +import Data.Postgres.Interval as Interval import Data.Postgres.Query.Builder as Q import Data.Postgres.Raw (Raw, jsNull) import Data.Postgres.Raw as Raw import Data.Postgres.Result (class FromRow) -import Data.RFC3339String as DateTime.ISO import Data.String as String import Data.Time (Time(..)) +import Data.Time.Duration (class Duration, Days, Hours, Milliseconds, Minutes, Seconds) import Data.Traversable (for, sequence) import Data.Tuple.Nested ((/\)) import Effect (Effect) @@ -35,20 +35,45 @@ import Effect.Unsafe (unsafePerformEffect) import Foreign (Foreign, unsafeToForeign) import Foreign.Object as Object import JS.BigInt (BigInt) -import JS.BigInt as BigInt import Node.Buffer (Buffer) import Node.Buffer as Buffer import Partial.Unsafe (unsafePartial) import Simple.JSON (writeJSON) -import Test.Common (withClient, withPoolClient) +import Test.Common (withPoolClient) import Test.QuickCheck (class Arbitrary, arbitrary, randomSeed) import Test.QuickCheck.Gen (sample, vectorOf) -import Test.Spec (Spec, SpecT, around, describe, it, parallel) +import Test.Spec (Spec, SpecT, around, describe, it) import Test.Spec.Assertions (fail) foreign import readBigInt64BE :: Buffer -> Effect BigInt foreign import dbg :: forall a. a -> Effect Unit +newtype GenIntervalSubMonth = GenIntervalSubMonth Interval + +derive instance Newtype GenIntervalSubMonth _ +instance Arbitrary GenIntervalSubMonth where + arbitrary = do + days <- chooseInt 0 30 + hours <- chooseInt 0 23 + minutes <- chooseInt 0 59 + seconds <- chooseInt 0 59 + milliseconds <- chooseFloat 0.0 999.9 + pure $ wrap $ Interval.make $ Interval.zero {days = days, hours = hours, minutes = minutes, seconds = seconds, milliseconds = milliseconds} + +newtype GenInterval = GenInterval Interval + +derive instance Newtype GenInterval _ +instance Arbitrary GenInterval where + arbitrary = do + years <- chooseInt 0 10 + months <- chooseInt 0 11 + days <- chooseInt 0 30 + hours <- chooseInt 0 23 + minutes <- chooseInt 0 59 + seconds <- chooseInt 0 59 + milliseconds <- chooseFloat 0.0 999.9 + pure $ wrap $ Interval.make {years, months, days, hours, minutes, seconds, milliseconds} + newtype GenSmallInt = GenSmallInt Int derive instance Newtype GenSmallInt _ @@ -196,6 +221,17 @@ spec = around withPoolClient $ describe "Data.Postgres" $ do + let + durationFromGenInterval :: forall d. Semigroup d => Duration d => Newtype d Number => GenIntervalSubMonth -> d + durationFromGenInterval = fromMaybe (wrap 0.0) <<< Interval.toDuration <<< unwrap + durationEq :: forall d. Duration d => Newtype d Number => d -> d -> Boolean + durationEq a b = Number.abs (unwrap a - unwrap b) <= 0.001 + check @Milliseconds @GenIntervalSubMonth { purs: "Milliseconds", sql: "interval", fromArb: durationFromGenInterval, isEq: durationEq} + check @Seconds @GenIntervalSubMonth { purs: "Seconds", sql: "interval", fromArb: durationFromGenInterval, isEq: durationEq} + check @Minutes @GenIntervalSubMonth { purs: "Minutes", sql: "interval", fromArb: durationFromGenInterval, isEq: durationEq} + check @Hours @GenIntervalSubMonth { purs: "Hours", sql: "interval", fromArb: durationFromGenInterval, isEq: durationEq} + check @Days @GenIntervalSubMonth { purs: "Days", sql: "interval", fromArb: durationFromGenInterval, isEq: durationEq} + check @Int @GenSmallInt { purs: "Int", sql: "int2", fromArb: unwrap, isEq: eq } check @Int { purs: "Int", sql: "int4", fromArb: identity, isEq: eq } check @String @GenString { purs: "String", sql: "text", fromArb: unwrap, isEq: eq } diff --git a/test/Test.Main.purs b/test/Test.Main.purs index 1da8b63..de0ae64 100644 --- a/test/Test.Main.purs +++ b/test/Test.Main.purs @@ -23,6 +23,7 @@ import Node.EventEmitter as Event import Test.Control.Monad.Postgres as Test.Control.Monad.Postgres import Test.Data.Postgres as Test.Data.Postgres import Test.Data.Postgres.Custom as Test.Data.Postgres.Custom +import Test.Data.Postgres.Interval as Test.Data.Postgres.Interval import Test.Effect.Postgres.Client as Test.Effect.Postgres.Client import Test.Effect.Postgres.Pool as Test.Effect.Postgres.Pool import Test.Spec.Reporter (specReporter) @@ -65,6 +66,7 @@ main = launchAff_ do $ runSpec [ specReporter ] do Test.Data.Postgres.Custom.spec Test.Data.Postgres.spec + Test.Data.Postgres.Interval.spec Test.Effect.Postgres.Client.spec Test.Effect.Postgres.Pool.spec Test.Control.Monad.Postgres.spec