Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for datetimeoffset data type #26

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions app/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import Control.Exception
import Data.List
import Data.Time.LocalTime (ZonedTime(..))
import qualified Data.Text as T
import qualified Data.Text.IO as T
import qualified Database.ODBC.Internal as ODBC
Expand Down Expand Up @@ -62,3 +63,4 @@ repl c = do
ODBC.ByteValue b -> show b
ODBC.TimeOfDayValue v -> show v
ODBC.LocalTimeValue v -> show v
ODBC.ZonedTimeValue lt tz -> show $ ZonedTime lt tz
56 changes: 56 additions & 0 deletions cbits/odbc.c
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,59 @@ SQLUSMALLINT TIMESTAMP_STRUCT_second(TIMESTAMP_STRUCT *t){
SQLUINTEGER TIMESTAMP_STRUCT_fraction(TIMESTAMP_STRUCT *t){
return t->fraction;
}

chrisdone marked this conversation as resolved.
Show resolved Hide resolved
////////////////////////////////////////////////////////////////////////////////
// Definition and accessors for SQL_SS_TIMESTAMPOFFSET_STRUCT
// The strcut definition is from
// https://docs.microsoft.com/en-us/sql/relational-databases/native-client-odbc-date-time/data-type-support-for-odbc-date-and-time-improvements
typedef struct tagTIMESTAMPOFFSET_STRUCT {
SQLSMALLINT year;
SQLUSMALLINT month;
SQLUSMALLINT day;
SQLUSMALLINT hour;
SQLUSMALLINT minute;
SQLUSMALLINT second;
SQLUINTEGER fraction;
SQLSMALLINT timezone_hour;
SQLSMALLINT timezone_minute;
} TIMESTAMPOFFSET_STRUCT;

#if (ODBCVER >= 0x0300)
typedef TIMESTAMPOFFSET_STRUCT SQL_SS_TIMESTAMPOFFSET_STRUCT;
#endif

SQLSMALLINT TIMESTAMPOFFSET_STRUCT_year(TIMESTAMPOFFSET_STRUCT *t){
return t->year;
}

SQLUSMALLINT TIMESTAMPOFFSET_STRUCT_month(TIMESTAMPOFFSET_STRUCT *t){
return t->month;
}

SQLUSMALLINT TIMESTAMPOFFSET_STRUCT_day(TIMESTAMPOFFSET_STRUCT *t){
return t->day;
}

SQLUSMALLINT TIMESTAMPOFFSET_STRUCT_hour(TIMESTAMPOFFSET_STRUCT *t){
return t->hour;
}

SQLUSMALLINT TIMESTAMPOFFSET_STRUCT_minute(TIMESTAMPOFFSET_STRUCT *t){
return t->minute;
}

SQLUSMALLINT TIMESTAMPOFFSET_STRUCT_second(TIMESTAMPOFFSET_STRUCT *t){
return t->second;
}

SQLUINTEGER TIMESTAMPOFFSET_STRUCT_fraction(TIMESTAMPOFFSET_STRUCT *t){
return t->fraction;
}

SQLSMALLINT TIMESTAMPOFFSET_STRUCT_timezone_hour(TIMESTAMPOFFSET_STRUCT *t){
return t->timezone_hour;
}

SQLSMALLINT TIMESTAMPOFFSET_STRUCT_timezone_minute(TIMESTAMPOFFSET_STRUCT *t){
return t->timezone_minute;
}
1 change: 1 addition & 0 deletions odbc.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ executable odbc
odbc,
bytestring,
text,
time,
optparse-applicative

test-suite test
Expand Down
6 changes: 6 additions & 0 deletions src/Database/ODBC/Conversion.hs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ instance FromValue LocalTime where
LocalTimeValue x -> pure (id x)
v -> Left ("Expected LocalTime, but got: " ++ show v))

instance FromValue ZonedTime where
fromValue =
(\case
ZonedTimeValue lt tz -> pure (ZonedTime lt tz)
v -> Left ("Expected ZonedTime, but got: " ++ show v))

--------------------------------------------------------------------------------
-- Producing rows

Expand Down
72 changes: 71 additions & 1 deletion src/Database/ODBC/Internal.hs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ data Value
-- ^ Time of day (hh, mm, ss + fractional) values.
| LocalTimeValue !LocalTime
-- ^ Local date and time.
| ZonedTimeValue !LocalTime !TimeZone
-- ^ Date and time with time zone.
| NullValue
-- ^ SQL null value.
deriving (Eq, Show, Typeable, Ord, Generic, Data)
Expand Down Expand Up @@ -605,6 +607,40 @@ getData dbc stmt i col =
(fmap fromIntegral (odbc_TIME_STRUCT_hour datePtr)) <*>
(fmap fromIntegral (odbc_TIME_STRUCT_minute datePtr)) <*>
(fmap fromIntegral (odbc_TIME_STRUCT_second datePtr))))
| colType == sql_ss_timestampoffset ->
withCallocBytes
20
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be 10 bytes? Why 20?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure, but I have a theory. I'd like to air it here; please let me know if this sounds completely bonkers.

I couldn't find anything in the documentation that states the size of the TIMESTAMPOFFSET_STRUCT, so I added the sizes of the constituent elements of the struct together. 3 SQLSMALLINT + 5 SQLUSMALLINT + 1 SQLUINTEGER. I used the conversion table in https://docs.microsoft.com/en-us/sql/odbc/reference/appendixes/c-data-types to translate those to C types, and from there I guessed at the size of each, based on https://en.wikipedia.org/wiki/C_data_types. I guessed that short int and unsigned short int would both be 2 bytes (the minimum size) and unsigned long int would be 4 bytes (again, the minimum size). That's 3 * 2 + 5 + 2 + 1 * 4 = 20.

I've noticed that https://docs.microsoft.com/en-us/sql/t-sql/data-types/datetimeoffset-transact-sql states that the storage size of the datetimeoffset column type is 10 bytes, but I suppose that that's because it uses a more compact data format (something like nanoseconds since some zero date, plus the time zone offset...).

At a more pragmatic level, the code works when I allocate 20 bytes. If I try to allocate 10 bytes, it doesn't work:

  test\Main.hs:343:
  1) Database.ODBC.SQLServer, Conversion to SQL, QuickCheck roundtrip: HS=Datetimeoffset, SQL=datetimeoffset
       uncaught exception: ODBCException (UnsuccessfulReturnCode "getTypedData ty=SQLCTYPE (-2)" (-1) "[Microsoft][ODBC Driver 17 for SQL Server]Numeric value out of range[Microsoft][ODBC Driver 17 for SQL Server]Numeric value out of range{") (after 1 test)
       Datetimeoffset {unDatetimeoffset = 1777-07-20 21:45:49.3091726 +0824}

  test\Main.hs:343:
  2) Database.ODBC.SQLServer, Conversion to SQL, QuickCheck roundtrip: HS=Maybe Datetimeoffset, SQL=datetimeoffset
       uncaught exception: ODBCException (UnsuccessfulReturnCode "getTypedData ty=SQLCTYPE (-2)" (-1) "[Microsoft][ODBC Driver 17 for SQL Server]Numeric value out of range[Microsoft][ODBC Driver 17 for SQL Server]Numeric value out of range{") (after 1 test)
       Datetimeoffset {unDatetimeoffset = 1777-07-20 21:45:49.3091726 +0824}

Copy link
Contributor

@chrisdone chrisdone May 16, 2019

Choose a reason for hiding this comment

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

Fair enough. 👍 If you can document that somewhere, then the code will be self-explanatory. Allocating 20 bytes isn't a big deal more than 10, I was just curious why it differed to the claim I saw in MSDN.

  test\Main.hs:343:
  1) Database.ODBC.SQLServer, Conversion to SQL, QuickCheck roundtrip: HS=Datetimeoffset, SQL=datetimeoffset
       uncaught exception: ODBCException (UnsuccessfulReturnCode "getTypedData ty=SQLCTYPE (-2)" (-1) "[Microsoft][ODBC Driver 17 for SQL Server]Numeric value out of range[Microsoft][ODBC Driver 17 for SQL Server]Numeric value out of range{") (after 1 test)
       Datetimeoffset {unDatetimeoffset = 1777-07-20 21:45:49.3091726 +0824}

Note to self, fix that error reporting. It's truncated intentionally, but out of laziness.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Indeed, I've now added a comment in a new commit.

FWIW, I had in mind doing that all along, but I felt the need to run the above explanation by someone first, as I wasn't entirely sure that it was correct.

(\datePtr -> do
mlen <-
getTypedData
dbc
stmt
sql_c_binary
i
(coerce datePtr)
(SQLLEN 20)
chrisdone marked this conversation as resolved.
Show resolved Hide resolved
case mlen of
Nothing -> pure NullValue
Just {} ->
liftM2
ZonedTimeValue
(LocalTime <$>
(fromGregorian <$>
(fmap fromIntegral (odbc_TIMESTAMPOFFSET_STRUCT_year datePtr)) <*>
(fmap fromIntegral (odbc_TIMESTAMPOFFSET_STRUCT_month datePtr)) <*>
(fmap fromIntegral (odbc_TIMESTAMPOFFSET_STRUCT_day datePtr))) <*>
(TimeOfDay <$>
(fmap fromIntegral (odbc_TIMESTAMPOFFSET_STRUCT_hour datePtr)) <*>
(fmap fromIntegral (odbc_TIMESTAMPOFFSET_STRUCT_minute datePtr)) <*>
(liftM2 (+)
(fmap fromIntegral (odbc_TIMESTAMPOFFSET_STRUCT_second datePtr))
(fmap ((/ 1000000000) . fromIntegral) (odbc_TIMESTAMPOFFSET_STRUCT_fraction datePtr)))))
(TimeZone <$>
(liftM2 (+)
(fmap ((* 60) . fromIntegral) (odbc_TIMESTAMPOFFSET_STRUCT_timezone_hour datePtr))
(fmap fromIntegral (odbc_TIMESTAMPOFFSET_STRUCT_timezone_minute datePtr))) <*>
pure False <*>
pure ""))
| colType == sql_type_timestamp ->
withMallocBytes
16
Expand Down Expand Up @@ -889,6 +925,9 @@ data TIME_STRUCT
-- https://docs.microsoft.com/en-us/sql/odbc/reference/appendixes/c-data-types
data TIMESTAMP_STRUCT

-- https://docs.microsoft.com/en-us/sql/relational-databases/native-client-odbc-date-time/data-type-support-for-odbc-date-and-time-improvements?view=sql-server-2017
data TIMESTAMPOFFSET_STRUCT

--------------------------------------------------------------------------------
-- Foreign functions

Expand Down Expand Up @@ -980,6 +1019,33 @@ foreign import ccall "odbc TIMESTAMP_STRUCT_second" odbc_TIMESTAMP_STRUCT_second
foreign import ccall "odbc TIMESTAMP_STRUCT_fraction" odbc_TIMESTAMP_STRUCT_fraction
:: Ptr TIMESTAMP_STRUCT -> IO SQLUINTEGER

foreign import ccall "odbc TIMESTAMPOFFSET_STRUCT_year" odbc_TIMESTAMPOFFSET_STRUCT_year
:: Ptr TIMESTAMPOFFSET_STRUCT -> IO SQLSMALLINT

foreign import ccall "odbc TIMESTAMPOFFSET_STRUCT_month" odbc_TIMESTAMPOFFSET_STRUCT_month
:: Ptr TIMESTAMPOFFSET_STRUCT -> IO SQLUSMALLINT

foreign import ccall "odbc TIMESTAMPOFFSET_STRUCT_day" odbc_TIMESTAMPOFFSET_STRUCT_day
:: Ptr TIMESTAMPOFFSET_STRUCT -> IO SQLUSMALLINT

foreign import ccall "odbc TIMESTAMPOFFSET_STRUCT_hour" odbc_TIMESTAMPOFFSET_STRUCT_hour
:: Ptr TIMESTAMPOFFSET_STRUCT -> IO SQLUSMALLINT

foreign import ccall "odbc TIMESTAMPOFFSET_STRUCT_minute" odbc_TIMESTAMPOFFSET_STRUCT_minute
:: Ptr TIMESTAMPOFFSET_STRUCT -> IO SQLUSMALLINT

foreign import ccall "odbc TIMESTAMPOFFSET_STRUCT_second" odbc_TIMESTAMPOFFSET_STRUCT_second
:: Ptr TIMESTAMPOFFSET_STRUCT -> IO SQLUSMALLINT

foreign import ccall "odbc TIMESTAMPOFFSET_STRUCT_fraction" odbc_TIMESTAMPOFFSET_STRUCT_fraction
:: Ptr TIMESTAMPOFFSET_STRUCT -> IO SQLUINTEGER

foreign import ccall "odbc TIMESTAMPOFFSET_STRUCT_timezone_hour" odbc_TIMESTAMPOFFSET_STRUCT_timezone_hour
:: Ptr TIMESTAMPOFFSET_STRUCT -> IO SQLSMALLINT

foreign import ccall "odbc TIMESTAMPOFFSET_STRUCT_timezone_minute" odbc_TIMESTAMPOFFSET_STRUCT_timezone_minute
:: Ptr TIMESTAMPOFFSET_STRUCT -> IO SQLSMALLINT

chrisdone marked this conversation as resolved.
Show resolved Hide resolved
--------------------------------------------------------------------------------
-- Foreign utils

Expand Down Expand Up @@ -1055,6 +1121,10 @@ sql_type_date = 91
sql_ss_time2 :: SQLSMALLINT
sql_ss_time2 = -154

-- ibid.
sql_ss_timestampoffset :: SQLSMALLINT
sql_ss_timestampoffset = -155

-- sql_datetime :: SQLSMALLINT
-- sql_datetime = 9

Expand Down Expand Up @@ -1152,4 +1222,4 @@ sql_c_type_timestamp :: SQLCTYPE
sql_c_type_timestamp = coerce sql_type_timestamp

sql_c_time :: SQLCTYPE
sql_c_time = coerce sql_time
sql_c_time = coerce sql_time
41 changes: 41 additions & 0 deletions src/Database/ODBC/SQLServer.hs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module Database.ODBC.SQLServer
, Internal.Binary(..)
, Datetime2(..)
, Smalldatetime(..)
, Datetimeoffset(..)

-- * Streaming results
-- $streaming
Expand Down Expand Up @@ -257,6 +258,22 @@ newtype Smalldatetime = Smalldatetime
{ unSmalldatetime :: LocalTime
} deriving (Eq, Ord, Show, Typeable, Generic, Data, FromValue)

newtype Datetimeoffset = Datetimeoffset
{ unDatetimeoffset :: ZonedTime
} deriving (Show, Typeable, Generic, Data, FromValue)

-- SQL Server considers two datetimeoffset values to be equal as long as they
-- represent the same instant in time; i.e. they are equavalent to the same UTC
-- time and date. This instance reproduces that behaviour.
instance Eq Datetimeoffset where
Datetimeoffset x == Datetimeoffset y = zonedTimeToUTC x == zonedTimeToUTC y

-- SQL Server considers datetimeoffset values to be ordered according to their
-- UTC equivalent values. This instance reproduces that behaviour.
instance Ord Datetimeoffset where
compare (Datetimeoffset x) (Datetimeoffset y) =
compare (zonedTimeToUTC x) (zonedTimeToUTC y)

chrisdone marked this conversation as resolved.
Show resolved Hide resolved
--------------------------------------------------------------------------------
-- Conversion to SQL

Expand Down Expand Up @@ -376,6 +393,9 @@ instance ToSql Smalldatetime where
shrink (LocalTime dd (TimeOfDay hh mm _ss)) =
LocalTime dd (TimeOfDay hh mm 0)

instance ToSql Datetimeoffset where
toSql (Datetimeoffset (ZonedTime lt tzone)) = toSql $ ZonedTimeValue lt tzone

--------------------------------------------------------------------------------
-- Top-level functions

Expand Down Expand Up @@ -487,6 +507,19 @@ renderValue =
hh
mm
(renderFractional ss)
ZonedTimeValue (LocalTime d (TimeOfDay hh mm ss)) tzone ->
Formatting.sformat
("'" % Formatting.dateDash % " " % Formatting.left 2 '0' % ":" %
Formatting.left 2 '0' %
":" %
Formatting.string %
Formatting.string %
"'")
d
hh
mm
(renderFractional ss)
(renderTimeZone tzone)

-- | Obviously, this is not fast. But it is correct. A faster version
-- can be written later.
Expand All @@ -498,6 +531,14 @@ renderFractional x = trim (printf "%.7f" (realToFrac x :: Double) :: String)
s'@('.':_) -> '0' : s'
s' -> s')

renderTimeZone :: TimeZone -> String
renderTimeZone (TimeZone 0 _ _) = "Z"
renderTimeZone (TimeZone t _ _) | t < 0 = '-' : renderTimeZone' (negate t)
renderTimeZone (TimeZone t _ _) = '+' : renderTimeZone' t

renderTimeZone' :: Int -> String
renderTimeZone' t = printf "%02d:%02d" (t `div` 60) (t `mod` 60)
chrisdone marked this conversation as resolved.
Show resolved Hide resolved

-- | A very conservative character escape.
escapeChar8 :: Word8 -> Text
escapeChar8 ch =
Expand Down
13 changes: 12 additions & 1 deletion test/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import Data.Word
import Database.ODBC.Conversion (FromValue(..))
import Database.ODBC.Internal (Value (..), Connection, ODBCException(..), Step(..), Binary)
import qualified Database.ODBC.Internal as Internal
import Database.ODBC.SQLServer (Datetime2(..), Smalldatetime(..), ToSql(..))
import Database.ODBC.SQLServer (Datetime2(..), Smalldatetime(..), Datetimeoffset(..), ToSql(..))
import qualified Database.ODBC.SQLServer as SQLServer
import Database.ODBC.TH (partsParser, Part(..))
import System.Environment
Expand Down Expand Up @@ -146,6 +146,7 @@ conversionTo = do
quickCheckRoundtrip @Datetime2 "Datetime2" "datetime2"
quickCheckRoundtrip @Smalldatetime "Smalldatetime" "smalldatetime"
quickCheckRoundtrip @TestDateTime "TestDateTime" "datetime"
quickCheckRoundtrip @Datetimeoffset "Datetimeoffset" "datetimeoffset"
chrisdone marked this conversation as resolved.
Show resolved Hide resolved
quickCheckOneway @TimeOfDay "TimeOfDay" "time"
quickCheckRoundtrip @TestTimeOfDay "TimeOfDay" "time"
quickCheckRoundtrip @Float "Float" "real"
Expand Down Expand Up @@ -678,3 +679,13 @@ instance Arbitrary Smalldatetime where
pure
(Smalldatetime
(LocalTime day (timeToTimeOfDay (secondsToDiffTime (minutes * 60)))))

instance Arbitrary Datetimeoffset where
arbitrary = do
lt <- arbitrary
-- Pick a time zone offset between -12 hours and +14 hours. According to
-- https://en.wikipedia.org/wiki/List_of_UTC_time_offsets the lowest offset
-- is -12 hours (at Baker Island and Howland Island), while the highest
-- offset is +14 hours (at Line Islands).
offset <- choose (-12 * 60, 14 * 60)
chrisdone marked this conversation as resolved.
Show resolved Hide resolved
return $ Datetimeoffset $ ZonedTime lt $ TimeZone offset False ""