Skip to content

Commit b9933eb

Browse files
authored
DATETIME: fix 1/300 of a seconds rounding logic (Bulk Copy related) (#242)
* Extract functions to convert three hundredths of a second to nanos This commit doesn't change any logic. Just extracts the functions which convert 1/300 of a second to nanos and back. * Add failing test TestDatetimeAccuracy which tests DATETIME accuracy Related issue: #181 * Fix rounding when nanos are converted to 1/300 of a second We want rounding to be done half away from zero, like math.Round does. See: https://learn.microsoft.com/en-us/sql/t-sql/data-types/datetime-transact-sql?view=sql-server-ver16#rounding-of-datetime-fractional-second-precision This commit fixes an issue: #181
1 parent e804768 commit b9933eb

File tree

2 files changed

+160
-2
lines changed

2 files changed

+160
-2
lines changed

types.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ func encodeDateTime(t time.Time) (res []byte) {
296296
basedays := gregorianDays(1900, 1)
297297
// days since Jan 1st 1900 (same TZ as t)
298298
days := gregorianDays(t.Year(), t.YearDay()) - basedays
299-
tm := 300*(t.Second()+t.Minute()*60+t.Hour()*60*60) + t.Nanosecond()*300/1e9
299+
tm := 300*(t.Second()+t.Minute()*60+t.Hour()*60*60) + nanosToThreeHundredthsOfASecond(t.Nanosecond())
300300
// minimum and maximum possible
301301
mindays := gregorianDays(1753, 1) - basedays
302302
maxdays := gregorianDays(9999, 365) - basedays
@@ -317,12 +317,20 @@ func encodeDateTime(t time.Time) (res []byte) {
317317
func decodeDateTime(buf []byte) time.Time {
318318
days := int32(binary.LittleEndian.Uint32(buf))
319319
tm := binary.LittleEndian.Uint32(buf[4:])
320-
ns := int(math.Trunc(float64(tm%300)/0.3+0.5)) * 1000000
320+
ns := threeHundredthsOfASecondToNanos(int(tm % 300))
321321
secs := int(tm / 300)
322322
return time.Date(1900, 1, 1+int(days),
323323
0, 0, secs, ns, time.UTC)
324324
}
325325

326+
func threeHundredthsOfASecondToNanos(ths int) int {
327+
return int(math.Trunc(float64(ths)/0.3+0.5)) * 1000000
328+
}
329+
330+
func nanosToThreeHundredthsOfASecond(ns int) int {
331+
return int(math.Round(float64(ns) * 3 / 1e7))
332+
}
333+
326334
func readFixedType(ti *typeInfo, r *tdsBuffer, c *cryptoMetadata) interface{} {
327335
r.ReadFull(ti.Buffer)
328336
buf := ti.Buffer

types_date_and_time_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package mssql
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
"time"
8+
)
9+
10+
// TestDatetimeAccuracy validates that DATETIME type values
11+
// are properly created from time.Time giving the same rounding
12+
// logic as SQL Server itself implements.
13+
//
14+
// Datetime is rounded to increments of .000, .003, or .007 seconds (accuracy is 1/300 of a second)
15+
// (see: https://learn.microsoft.com/en-us/sql/t-sql/data-types/datetime-transact-sql?view=sql-server-ver16).
16+
//
17+
// This test creates 3 schematically identical tables filled in 3 different ways:
18+
//
19+
// - datetime_test_insert_time_as_str (filled via regular INSERT with time as str params)
20+
// - datetime_test_insert_time_as_time (filled via regular INSERT with time as go time.Time params)
21+
// - datetime_test_insert_bulk (filled via Bulk Copy)
22+
//
23+
// After creating these 3 tables the test will compare the data read from all of the tables
24+
// expecting the data to be identical.
25+
func TestDatetimeAccuracy(t *testing.T) {
26+
ctx := context.Background()
27+
conn, logger := open(t)
28+
t.Cleanup(func() {
29+
conn.Close()
30+
logger.StopLogging()
31+
})
32+
33+
createTable := func(tableName string) {
34+
_, err := conn.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName))
35+
if err != nil {
36+
t.Fatal("Failed to drop table: ", err)
37+
}
38+
_, err = conn.Exec(fmt.Sprintf(`CREATE TABLE %s (
39+
id INT NOT NULL PRIMARY KEY,
40+
dt DATETIME
41+
)`, tableName))
42+
if err != nil {
43+
t.Fatal("Failed to create table: ", err)
44+
}
45+
t.Cleanup(func() { _, _ = conn.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName)) })
46+
}
47+
48+
fillTable := func(tableName string, dts []any) {
49+
for i, dt := range dts {
50+
_, err := conn.Exec(fmt.Sprintf("INSERT INTO %s (id, dt) VALUES (@p1, @p2)", tableName), i, dt)
51+
if err != nil {
52+
t.Fatal(err)
53+
}
54+
}
55+
}
56+
57+
fillTableBulkCopy := func(tableName string, dts []any) {
58+
conn, err := conn.Conn(context.Background())
59+
if err != nil {
60+
t.Fatal(err)
61+
}
62+
defer func() { _ = conn.Close() }()
63+
stmt, err := conn.PrepareContext(ctx, CopyIn(tableName, BulkOptions{}, "id", "dt"))
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
for i, dt := range dts {
68+
if _, err = stmt.Exec(i, dt); err != nil {
69+
t.Fatal(err)
70+
}
71+
}
72+
if _, err = stmt.Exec(); err != nil {
73+
t.Fatal(err)
74+
}
75+
}
76+
77+
readTable := func(tableName string) (res []time.Time) {
78+
rows, err := conn.QueryContext(ctx, fmt.Sprintf("SELECT dt FROM %s ORDER BY id", tableName))
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
defer func() { _ = rows.Close() }()
83+
for rows.Next() {
84+
var dt time.Time
85+
if err = rows.Scan(&dt); err != nil {
86+
t.Fatal(err)
87+
}
88+
res = append(res, dt)
89+
}
90+
if rows.Err() != nil {
91+
t.Fatal(rows.Err())
92+
}
93+
return res
94+
}
95+
96+
// generate data to be inserted into the tables:
97+
// times with fraction of a second from .000 to .999.
98+
var dtsTime []any
99+
var dtsStrs []any
100+
for i := 0; i < 1000; i++ {
101+
ns := int(time.Duration(i) * (time.Second / 1000) * time.Nanosecond)
102+
dt := time.Date(2025, 4, 11, 10, 30, 42, ns, time.UTC)
103+
str := dt.Format("2006-01-02T15:04:05.999Z")
104+
dtsTime = append(dtsTime, dt)
105+
dtsStrs = append(dtsStrs, str)
106+
}
107+
108+
createTable("datetime_test_insert_time_as_str")
109+
fillTable("datetime_test_insert_time_as_str", dtsStrs)
110+
111+
createTable("datetime_test_insert_time_as_time")
112+
fillTable("datetime_test_insert_time_as_time", dtsTime)
113+
114+
createTable("datetime_test_insert_bulk")
115+
fillTableBulkCopy("datetime_test_insert_bulk", dtsTime)
116+
117+
as := readTable("datetime_test_insert_time_as_str")
118+
bs := readTable("datetime_test_insert_time_as_time")
119+
cs := readTable("datetime_test_insert_bulk")
120+
121+
if len(dtsTime) != len(as) || len(dtsTime) != len(bs) || len(dtsTime) != len(cs) {
122+
t.Fatalf("Not all data inserted into tables: want = %d, got = %d %d %d", len(dtsTime), len(as), len(bs), len(cs))
123+
}
124+
125+
for i := 0; i < len(dtsTime); i++ {
126+
if !as[i].Equal(bs[i]) || !as[i].Equal(cs[i]) {
127+
t.Fatalf(`Rows not equal at #%d:
128+
| %-36s | %-36s | %-36s |
129+
| %36s | %36s | %36s |`,
130+
i,
131+
"datetime_test_insert_time_as_str",
132+
"datetime_test_insert_time_as_time",
133+
"datetime_test_insert_bulk",
134+
as[i].Format(time.RFC3339Nano),
135+
bs[i].Format(time.RFC3339Nano),
136+
cs[i].Format(time.RFC3339Nano),
137+
)
138+
}
139+
}
140+
}
141+
142+
func TestThreeHundredthsToNanosConverter(t *testing.T) {
143+
for want := 0; want < 300; want++ {
144+
ns := threeHundredthsOfASecondToNanos(want)
145+
got := nanosToThreeHundredthsOfASecond(ns)
146+
if want != got {
147+
t.Fatalf("want = %d, got = %d", want, got)
148+
}
149+
}
150+
}

0 commit comments

Comments
 (0)