Skip to content

Commit 29cb60a

Browse files
authored
Fix date and timestamptz display. closes #4450
1 parent fd91c17 commit 29cb60a

File tree

7 files changed

+356
-10
lines changed

7 files changed

+356
-10
lines changed

.github/workflows/11-test-acceptance.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ jobs:
101101
- "blank_aggregators"
102102
- "search_path"
103103
- "chaos_and_query"
104+
- "date_time_types"
104105
- "dynamic_schema"
105106
- "dynamic_aggregators"
106107
- "cache"

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ require (
1717
github.com/fatih/color v1.18.0
1818
github.com/fsnotify/fsnotify v1.9.0
1919
github.com/gertd/go-pluralize v0.2.1
20-
github.com/go-git/go-git/v5 v5.16.2
20+
github.com/go-git/go-git/v5 v5.16.5
2121
github.com/google/uuid v1.6.0
2222
github.com/hashicorp/go-hclog v1.6.3
2323
github.com/hashicorp/go-plugin v1.7.0
@@ -40,7 +40,7 @@ require (
4040
github.com/spf13/viper v1.20.1
4141
github.com/thediveo/enumflag/v2 v2.0.7
4242
github.com/turbot/go-kit v1.3.0
43-
github.com/turbot/pipe-fittings/v2 v2.7.2
43+
github.com/turbot/pipe-fittings/v2 v2.7.3
4444
github.com/turbot/steampipe-plugin-sdk/v5 v5.13.0
4545
github.com/turbot/terraform-components v0.0.0-20250114051614-04b806a9cbed
4646
github.com/zclconf/go-cty v1.16.3 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -828,8 +828,8 @@ github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UN
828828
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
829829
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
830830
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
831-
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
832-
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
831+
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
832+
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
833833
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
834834
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
835835
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -1258,8 +1258,8 @@ github.com/turbot/go-kit v1.3.0 h1:6cIYPAO5hO9fG7Zd5UBC4Ch3+C6AiiyYS0UQnrUlTV0=
12581258
github.com/turbot/go-kit v1.3.0/go.mod h1:piKJMYCF8EYmKf+D2B78Csy7kOHGmnQVOWingtLKWWQ=
12591259
github.com/turbot/go-prompt v0.2.6-steampipe.0.0.20221028122246-eb118ec58d50 h1:zs87uA6QZsYLk4RRxDOIxt8ro/B2V6HzoMWm05Lo7ao=
12601260
github.com/turbot/go-prompt v0.2.6-steampipe.0.0.20221028122246-eb118ec58d50/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw=
1261-
github.com/turbot/pipe-fittings/v2 v2.7.2 h1:fwyzK8tlQJK0SNGLxlZORT7pUI6ghkZwM+wIpf+LMW8=
1262-
github.com/turbot/pipe-fittings/v2 v2.7.2/go.mod h1:V619+tgfLaqoEXFDNzA2p24TBZVf4IkDL9FDLQecMnE=
1261+
github.com/turbot/pipe-fittings/v2 v2.7.3 h1:DacY/pc8zERJYXszkomJCOi1YDK3e2chJ1HEN6GCzgU=
1262+
github.com/turbot/pipe-fittings/v2 v2.7.3/go.mod h1:VYqcgGrYDLsGxn1r4dOkkEh5/KDEgJgUU+nf0SAODY0=
12631263
github.com/turbot/pipes-sdk-go v0.12.1 h1:mF9Z9Mr6F0uqlWjd1mQn+jqT24GPvWDFDrFTvmkazHc=
12641264
github.com/turbot/pipes-sdk-go v0.12.1/go.mod h1:iQE0ebN74yqiCRrfv7izxVMRcNlZftPWWDPsMFwejt4=
12651265
github.com/turbot/steampipe-plugin-sdk/v5 v5.13.0 h1:6GSmiKsPdMd2X1ULK17Q/8UQvNOpyub4F2a5nmMGkis=

pkg/db/db_client/db_client_execute.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,15 @@ quals from %s.%s order by duration_ms desc`, constants.InternalSchema, constants
272272
func (c *DbClient) startQuery(ctx context.Context, conn *pgx.Conn, query string, args ...any) (rows pgx.Rows, err error) {
273273
doneChan := make(chan bool)
274274
go func() {
275-
// start asynchronous query
276-
rows, err = conn.Query(ctx, query, args...)
275+
// Request text format for timestamptz so PostgreSQL returns the value
276+
// formatted in the session timezone, matching psql behavior.
277+
// By default pgx uses binary format which loses session timezone info.
278+
queryArgs := make([]any, 0, len(args)+1)
279+
queryArgs = append(queryArgs, pgx.QueryResultFormatsByOID{
280+
pgtype.TimestamptzOID: pgx.TextFormatCode,
281+
})
282+
queryArgs = append(queryArgs, args...)
283+
rows, err = conn.Query(ctx, query, queryArgs...)
277284
close(doneChan)
278285
}()
279286

@@ -351,6 +358,26 @@ func populateRow(columnValues []interface{}, cols []*pqueryresult.ColumnDef) ([]
351358
elements := utils.Map(arr, func(e interface{}) string { return e.(string) })
352359
result[i] = strings.Join(elements, ",")
353360
}
361+
case "_DATE":
362+
if arr, ok := columnValue.([]interface{}); ok {
363+
elements := utils.Map(arr, func(e interface{}) string {
364+
if t, ok := e.(time.Time); ok {
365+
return t.Format("2006-01-02")
366+
}
367+
return fmt.Sprintf("%v", e)
368+
})
369+
result[i] = strings.Join(elements, ",")
370+
}
371+
case "_TIMESTAMPTZ":
372+
if arr, ok := columnValue.([]interface{}); ok {
373+
elements := utils.Map(arr, func(e interface{}) string {
374+
if t, ok := e.(time.Time); ok {
375+
return t.Format(time.RFC3339)
376+
}
377+
return fmt.Sprintf("%v", e)
378+
})
379+
result[i] = strings.Join(elements, ",")
380+
}
354381
case "INET":
355382
if inet, ok := columnValue.(netip.Prefix); ok {
356383
result[i] = strings.TrimSuffix(inet.String(), "/32")
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package db_client
2+
3+
import (
4+
"os"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// TestTimestamptzTextFormatImplemented verifies that the timestamptz wire protocol fix is in place.
13+
// Reference: https://github.com/turbot/steampipe/issues/4450
14+
//
15+
// This test verifies that startQuery uses QueryResultFormatsByOID to request text format
16+
// for timestamptz columns, ensuring PostgreSQL formats values using the session timezone.
17+
//
18+
// Without this fix, pgx uses binary protocol which loses session timezone info, causing
19+
// timestamptz values to display in the local machine timezone instead of the session timezone.
20+
func TestTimestamptzTextFormatImplemented(t *testing.T) {
21+
// Read the db_client_execute.go file to verify the fix is present
22+
content, err := os.ReadFile("db_client_execute.go")
23+
require.NoError(t, err, "should be able to read db_client_execute.go")
24+
25+
sourceCode := string(content)
26+
27+
// Verify QueryResultFormatsByOID is used
28+
assert.Contains(t, sourceCode, "pgx.QueryResultFormatsByOID",
29+
"QueryResultFormatsByOID must be used to specify format for specific column types")
30+
31+
// Verify TimestamptzOID is referenced
32+
assert.Contains(t, sourceCode, "pgtype.TimestamptzOID",
33+
"TimestamptzOID must be specified to request text format for timestamptz columns")
34+
35+
// Verify TextFormatCode is used
36+
assert.Contains(t, sourceCode, "pgx.TextFormatCode",
37+
"TextFormatCode must be used to request text format")
38+
39+
// Verify the fix is in startQuery function
40+
funcStart := strings.Index(sourceCode, "func (c *DbClient) startQuery")
41+
assert.NotEqual(t, -1, funcStart, "startQuery function must exist")
42+
43+
// Extract just the startQuery function for more precise checking
44+
funcEnd := strings.Index(sourceCode[funcStart:], "\nfunc ")
45+
if funcEnd == -1 {
46+
funcEnd = len(sourceCode)
47+
} else {
48+
funcEnd += funcStart
49+
}
50+
startQueryFunc := sourceCode[funcStart:funcEnd]
51+
52+
// Verify all three components are in startQuery
53+
assert.Contains(t, startQueryFunc, "QueryResultFormatsByOID",
54+
"QueryResultFormatsByOID must be in startQuery function")
55+
assert.Contains(t, startQueryFunc, "TimestamptzOID",
56+
"TimestamptzOID must be in startQuery function")
57+
assert.Contains(t, startQueryFunc, "TextFormatCode",
58+
"TextFormatCode must be in startQuery function")
59+
60+
// Verify there's a comment explaining the fix
61+
hasComment := strings.Contains(startQueryFunc, "session timezone") ||
62+
strings.Contains(startQueryFunc, "text format for timestamptz") ||
63+
strings.Contains(startQueryFunc, "Request text format")
64+
assert.True(t, hasComment,
65+
"Comment should explain why text format is needed for timestamptz")
66+
67+
// Verify queryArgs are constructed and used
68+
assert.Contains(t, startQueryFunc, "queryArgs",
69+
"queryArgs variable must be used to prepend format specification")
70+
assert.Contains(t, startQueryFunc, "conn.Query(ctx, query, queryArgs...)",
71+
"conn.Query must use queryArgs instead of args directly")
72+
}
73+
74+
// TestTimestamptzFormatCorrectness verifies the format specification structure
75+
func TestTimestamptzFormatCorrectness(t *testing.T) {
76+
content, err := os.ReadFile("db_client_execute.go")
77+
require.NoError(t, err, "should be able to read db_client_execute.go")
78+
79+
sourceCode := string(content)
80+
81+
// Verify the QueryResultFormatsByOID is constructed as the first element
82+
// This is critical - it must be the first argument before actual query parameters
83+
assert.Contains(t, sourceCode, "queryArgs := make([]any, 0, len(args)+1)",
84+
"queryArgs must be allocated with capacity for format spec + args")
85+
86+
// Verify format spec is appended first
87+
lines := strings.Split(sourceCode, "\n")
88+
var foundMake, foundAppendFormat, foundAppendArgs bool
89+
var makeIdx, appendFormatIdx, appendArgsIdx int
90+
91+
for i, line := range lines {
92+
if strings.Contains(line, "queryArgs := make([]any, 0, len(args)+1)") {
93+
foundMake = true
94+
makeIdx = i
95+
}
96+
if strings.Contains(line, "queryArgs = append(queryArgs, pgx.QueryResultFormatsByOID{") {
97+
foundAppendFormat = true
98+
appendFormatIdx = i
99+
}
100+
if strings.Contains(line, "queryArgs = append(queryArgs, args...)") {
101+
foundAppendArgs = true
102+
appendArgsIdx = i
103+
}
104+
}
105+
106+
assert.True(t, foundMake, "queryArgs must be allocated")
107+
assert.True(t, foundAppendFormat, "format spec must be appended to queryArgs")
108+
assert.True(t, foundAppendArgs, "original args must be appended to queryArgs")
109+
110+
// Verify correct order: make -> append format spec -> append args
111+
if foundMake && foundAppendFormat && foundAppendArgs {
112+
assert.Less(t, makeIdx, appendFormatIdx,
113+
"queryArgs must be allocated before appending format spec")
114+
assert.Less(t, appendFormatIdx, appendArgsIdx,
115+
"format spec must be appended before original args")
116+
}
117+
}
118+
119+
// TestTimestamptzFormatDoesNotAffectOtherTypes verifies only timestamptz format is changed
120+
func TestTimestamptzFormatDoesNotAffectOtherTypes(t *testing.T) {
121+
content, err := os.ReadFile("db_client_execute.go")
122+
require.NoError(t, err, "should be able to read db_client_execute.go")
123+
124+
sourceCode := string(content)
125+
126+
// Find the QueryResultFormatsByOID map construction
127+
funcStart := strings.Index(sourceCode, "func (c *DbClient) startQuery")
128+
require.NotEqual(t, -1, funcStart, "startQuery function must exist")
129+
130+
funcEnd := strings.Index(sourceCode[funcStart:], "\nfunc ")
131+
if funcEnd == -1 {
132+
funcEnd = len(sourceCode)
133+
} else {
134+
funcEnd += funcStart
135+
}
136+
startQueryFunc := sourceCode[funcStart:funcEnd]
137+
138+
// Verify ONLY TimestamptzOID is in the map (no other OIDs)
139+
// This ensures we don't accidentally change format for other types
140+
otherOIDs := []string{
141+
"DateOID",
142+
"TimestampOID",
143+
"TimeOID",
144+
"IntervalOID",
145+
"JSONOID",
146+
"JSONBOID",
147+
}
148+
149+
for _, oid := range otherOIDs {
150+
assert.NotContains(t, startQueryFunc, "pgtype."+oid,
151+
"Should not change format for "+oid+" - only timestamptz needs text format")
152+
}
153+
154+
// Verify there's only one entry in QueryResultFormatsByOID
155+
// Count how many times we see "OID:" in the map definition
156+
oidCount := strings.Count(startQueryFunc, "OID:")
157+
assert.Equal(t, 1, oidCount,
158+
"QueryResultFormatsByOID should have exactly one entry (TimestamptzOID)")
159+
}

tests/acceptance/test_data/templates/expected_6.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@
115115
"booleancolumn": true,
116116
"cidrrange": "10.1.2.3/32",
117117
"currency": "$922,337,203,685,477.57",
118-
"date1": "1978-02-05 00:00:00",
118+
"date1": "1978-02-05",
119119
"floatcolumn": 4.681642125488754,
120120
"integercolumn1": 3278,
121121
"integercolumn2": 21445454,
@@ -147,7 +147,7 @@
147147
"textcolumn3": "This is a very long text for the PostgreSQL text column",
148148
"time1": "08:00:00",
149149
"timestamp1": "2016-06-22 19:10:25",
150-
"timestamp2": "2016-06-23T07:40:25+05:30",
150+
"timestamp2": "2016-06-23T02:10:25Z",
151151
"uuidcolumn": "6948df80-14bd-4e04-8842-7668d9c001f5",
152152
"xmldata": "<book><title>Manual</title><chapter>...</chapter></book>"
153153
}

0 commit comments

Comments
 (0)