Skip to content
Merged
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
236 changes: 236 additions & 0 deletions explain/build_args_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package explain_test

import (
"testing"
"time"

"github.com/mickamy/sql-tap/explain"
)

func TestParseTimestampParams(t *testing.T) {
t.Parallel()

tests := []struct {
name string
query string
want map[int]bool
}{
{
name: "no casts",
query: "SELECT * FROM users WHERE id = $1",
want: map[int]bool{},
},
{
name: "timestamp with time zone",
query: "SELECT * FROM t WHERE created_at > $1::TIMESTAMP WITH TIME ZONE",
want: map[int]bool{1: true},
},
{
name: "timestamptz",
query: "SELECT * FROM t WHERE ts = $2::TIMESTAMPTZ",
want: map[int]bool{2: true},
},
{
name: "timestamp without time zone",
query: "SELECT * FROM t WHERE ts = $3::TIMESTAMP WITHOUT TIME ZONE",
want: map[int]bool{3: true},
},
{
name: "plain timestamp",
query: "SELECT * FROM t WHERE ts = $1::TIMESTAMP",
want: map[int]bool{1: true},
},
{
name: "lowercase cast",
query: "SELECT * FROM t WHERE ts > $1::timestamp with time zone",
want: map[int]bool{1: true},
},
{
name: "spaces around ::",
query: "SELECT * FROM t WHERE ts = $1 :: TIMESTAMP",
want: map[int]bool{1: true},
},
{
name: "mixed: timestamp and non-timestamp",
query: "SELECT * FROM t WHERE key = $1::VARCHAR AND ts > $2::TIMESTAMP WITH TIME ZONE",
want: map[int]bool{2: true},
},
{
name: "multiple timestamp params",
query: "SELECT * FROM t WHERE a > $1::TIMESTAMP AND b < $3::TIMESTAMPTZ",
want: map[int]bool{1: true, 3: true},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := explain.ParseTimestampParams(tt.query)
if len(got) != len(tt.want) {
t.Fatalf("ParseTimestampParams(%q) = %v, want %v", tt.query, got, tt.want)
}
for k, v := range tt.want {
if got[k] != v {
t.Errorf("ParseTimestampParams(%q)[%d] = %v, want %v", tt.query, k, got[k], v)
}
}
})
}
}

func TestParsePGTimestamp(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input string
want time.Time
wantOK bool
}{
{
name: "PostgreSQL epoch (zero)",
input: "0",
want: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
wantOK: true,
},
{
name: "large microseconds value (issue repro: ~2026)",
input: "825505830505628",
want: func() time.Time {
microsecs := int64(825505830505628)
sec := microsecs / 1_000_000
usec := microsecs % 1_000_000
return time.Unix(sec+explain.PgEpochUnix, usec*1_000).UTC()
}(),
wantOK: true,
},
{
name: "negative (before 2000-01-01)",
input: "-1000000",
want: time.Date(1999, 12, 31, 23, 59, 59, 0, time.UTC),
wantOK: true,
},
{
name: "negative fractional (before 2000-01-01)",
input: "-1",
want: time.Date(1999, 12, 31, 23, 59, 59, 999999000, time.UTC),
wantOK: true,
},
{
name: "non-integer string",
input: "2026-02-27T14:10:30Z",
wantOK: false,
},
{
name: "float string",
input: "1.5",
wantOK: false,
},
{
name: "empty string",
input: "",
wantOK: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got, ok := explain.ParsePGTimestamp(tt.input)
if ok != tt.wantOK {
t.Fatalf("ParsePGTimestamp(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
}
if ok && !got.Equal(tt.want) {
t.Errorf("ParsePGTimestamp(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

func TestBuildAnyArgs(t *testing.T) {
t.Parallel()

pgEpoch := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)

tests := []struct {
name string
query string
args []string
check func(t *testing.T, got []any)
}{
{
name: "no args",
query: "SELECT 1",
args: nil,
check: func(t *testing.T, got []any) {
t.Helper()
if len(got) != 0 {
t.Errorf("expected empty slice, got %v", got)
}
},
},
{
name: "non-timestamp arg stays as string",
query: "SELECT * FROM users WHERE id = $1",
args: []string{"42"},
check: func(t *testing.T, got []any) {
t.Helper()
if s, ok := got[0].(string); !ok || s != "42" {
t.Errorf("got[0] = %v (%T), want string %q", got[0], got[0], "42")
}
},
},
{
name: "timestamp arg converted to time.Time",
query: "SELECT * FROM t WHERE expired_at > $1::TIMESTAMP WITH TIME ZONE",
args: []string{"825505830505628"},
check: func(t *testing.T, got []any) {
t.Helper()
ts, ok := got[0].(time.Time)
if !ok {
t.Fatalf("got[0] = %v (%T), want time.Time", got[0], got[0])
}
// The value should be ~2026 (pgEpoch + 825505830.5 seconds)
if ts.Before(pgEpoch) {
t.Errorf("got time %v, expected a time after 2000-01-01", ts)
}
},
},
{
name: "non-integer timestamp arg stays as string",
query: "SELECT * FROM t WHERE ts > $1::TIMESTAMP WITH TIME ZONE",
args: []string{"2026-02-27T14:10:30Z"},
check: func(t *testing.T, got []any) {
t.Helper()
if s, ok := got[0].(string); !ok || s != "2026-02-27T14:10:30Z" {
t.Errorf("got[0] = %v (%T), want string %q", got[0], got[0], "2026-02-27T14:10:30Z")
}
},
},
{
name: "mixed args: varchar and timestamp",
query: "SELECT * FROM t WHERE key = $1::VARCHAR AND ts > $2::TIMESTAMP WITH TIME ZONE",
args: []string{"019c5c4f-f25a-772b-97d4-1646a125080d", "825505830505628"},
check: func(t *testing.T, got []any) {
t.Helper()
if s, ok := got[0].(string); !ok || s != "019c5c4f-f25a-772b-97d4-1646a125080d" {
t.Errorf("got[0] = %v (%T), want string", got[0], got[0])
}
if _, ok := got[1].(time.Time); !ok {
t.Errorf("got[1] = %v (%T), want time.Time", got[1], got[1])
}
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := explain.BuildAnyArgs(tt.query, tt.args)
tt.check(t, got)
})
}
}
68 changes: 64 additions & 4 deletions explain/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -74,10 +76,7 @@ func NewClient(db *sql.DB, driver Driver) *Client {

// Run executes EXPLAIN or EXPLAIN ANALYZE for the given query with optional args.
func (c *Client) Run(ctx context.Context, mode Mode, query string, args []string) (*Result, error) {
anyArgs := make([]any, len(args))
for i, a := range args {
anyArgs[i] = a
}
anyArgs := buildAnyArgs(query, args)

// MySQL/TiDB cannot parse placeholder ? without args; replace with NULL for plan-only EXPLAIN.
q := query
Expand Down Expand Up @@ -126,6 +125,67 @@ func (c *Client) Run(ctx context.Context, mode Mode, query string, args []string
}, nil
}

// timestampCastRe matches PostgreSQL-style timestamp cast placeholders such as
// $1::TIMESTAMP, $2::TIMESTAMPTZ, $3::TIMESTAMP WITH TIME ZONE, etc.
// The prefix "TIMESTAMP" covers all variants because TIMESTAMPTZ and
// "TIMESTAMP WITH/WITHOUT TIME ZONE" all begin with that substring.
var timestampCastRe = regexp.MustCompile(`(?i)\$(\d+)\s*::\s*TIMESTAMP`)

// pgEpochUnix is the Unix timestamp of PostgreSQL's internal epoch (2000-01-01 00:00:00 UTC).
const pgEpochUnix int64 = 946684800

// buildAnyArgs converts string args to []any for use in QueryContext.
// For args whose corresponding query placeholder is cast to a timestamp type
// (e.g. $2::TIMESTAMP WITH TIME ZONE), it tries to interpret the value as a
// PostgreSQL binary-encoded timestamp (int64 microseconds since 2000-01-01 UTC)
// and converts it to time.Time. This prevents the "date/time field value out of
// range" error that occurs when a captured binary timestamp is re-used as a plain
// string in a parameterized EXPLAIN query.
func buildAnyArgs(query string, args []string) []any {
tsParams := parseTimestampParams(query)
anyArgs := make([]any, len(args))
for i, a := range args {
if tsParams[i+1] {
if t, ok := parsePGTimestamp(a); ok {
Comment on lines +144 to +149
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

buildAnyArgs() only converts integer args when the query text contains an explicit $N::TIMESTAMP... cast. However, in the Postgres proxy the captured query used for EXPLAIN is the raw Parse query (q := c.lastParse) and typically does not include explicit casts; parameter types are usually inferred from context (e.g., created_at > $1). In those cases this conversion will never trigger and EXPLAIN can still fail with SQLSTATE 22008 for binary-encoded timestamptz args. Consider using protocol type information instead of query-text regex (e.g., capture Parse.ParameterOIDs and/or per-parameter format codes in the event / ExplainRequest) so timestamp parameters can be detected even when there is no ::TIMESTAMP in the SQL.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@copilot fix this issue

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ad49ce2 by addressing the issue at the protocol level instead of relying on query-text regex.

Changes in proxy/postgres/conn.go:

  • handleParse now captures m.ParameterOIDs into lastParamOIDs (and preparedStmtOIDs for named statements)
  • handleBind looks up OIDs per parameter and passes them to decodeBinaryParam
  • decodeBinaryParam now detects timestamp OIDs (1114 = timestamp, 1184 = timestamptz) and decodes the 8-byte binary value using decodePGTimestampMicros → stores an RFC3339Nano string (e.g. "2026-02-27T14:10:30.505628Z") that PostgreSQL can parse back correctly in both explicit-cast and implicit-cast contexts

The existing buildAnyArgs regex in explain.go is kept as a fallback for drivers that don't send OIDs but have explicit $N::TIMESTAMP casts.

anyArgs[i] = t
continue
}
}
anyArgs[i] = a
}
return anyArgs
}

// parseTimestampParams returns the set of 1-indexed parameter numbers that are
// cast to a timestamp type in the query.
func parseTimestampParams(query string) map[int]bool {
m := make(map[int]bool)
for _, match := range timestampCastRe.FindAllStringSubmatch(query, -1) {
if n, err := strconv.Atoi(match[1]); err == nil {
m[n] = true
}
}
return m
}

// parsePGTimestamp attempts to interpret s as a PostgreSQL binary-encoded
// timestamp: an int64 number of microseconds since 2000-01-01 00:00:00 UTC.
// Returns the corresponding time.Time and true on success.
func parsePGTimestamp(s string) (time.Time, bool) {
microsecs, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return time.Time{}, false
}
sec := microsecs / 1_000_000
usec := microsecs % 1_000_000
// Normalize: for negative microsecs, usec is negative; carry into sec.
if usec < 0 {
sec--
usec += 1_000_000
}
return time.Unix(sec+pgEpochUnix, usec*1_000).UTC(), true
}

// Close closes the underlying database connection.
func (c *Client) Close() error {
if err := c.db.Close(); err != nil {
Expand Down
11 changes: 11 additions & 0 deletions explain/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package explain

// Exported wrappers for internal symbols used in package-external tests.

var (
BuildAnyArgs = buildAnyArgs
ParseTimestampParams = parseTimestampParams
ParsePGTimestamp = parsePGTimestamp
)

const PgEpochUnix = pgEpochUnix
Loading