go-sqlite3 icon indicating copy to clipboard operation
go-sqlite3 copied to clipboard

Scanning of timestamps not working when using "with time zone"

Open coma64 opened this issue 1 year ago • 1 comments

With a column of type "timestamp with time zone", like in the following table, sqlite3 doesn't convert it into a time.Time and scanning such a row into a time.Time field therefore fails.

create table snippets(
    id binary(16) primary key,
    content text not null,
    created_at timestamp with time zone not null default CURRENT_TIMESTAMP
);

I traced this to SQLiteRows.nextSyncLocked() where you check whether rc.decltype[i] is any of date, datetime or timestamp but in my case it's timestamp with time zone causing the value to not be converted from a string.

go version: go version go1.21.3 darwin/arm64

Code

type Snippet struct {
	Id        string
	Content   string
	CreatedAt time.Time `db:"created_at"`
}

snippet := Snippet{
  Id:      id.String(),
  Content: content,
}

if err = s.db.GetContext(ctx, &snippet, "insert into snippets(id, content) values ($1, $2) returning created_at", binaryId, content); err != nil {
  return nil, fmt.Errorf("inserting snippet: %w", err)
}

coma64 avatar Feb 03 '24 21:02 coma64


package main

import (
	"context"
	"database/sql"
	"fmt"
	"time"

	"github.com/google/uuid"
	_ "github.com/mattn/go-sqlite3"
	"github.com/pkg/errors"
)

// TimeWithZone is a custom type to handle SQLite's TIMESTAMP WITH TIME ZONE
type TimeWithZone struct {
	Time time.Time
}

// Scan implements the sql.Scanner interface to convert SQLite's TIMESTAMP WITH TIME ZONE string to time.Time
func (t *TimeWithZone) Scan(value interface{}) error {
	if value == nil {
		t.Time = time.Time{}
		return nil
	}

	// Expect value to be a string from SQLite
	str, ok := value.(string)
	if !ok {
		return fmt.Errorf("cannot scan %T into TimeWithZone", value)
	}

	// Parse the string in RFC3339 format or other expected format
	// SQLite typically stores TIMESTAMP WITH TIME ZONE as ISO8601 (e.g., "2025-06-22T12:09:00Z")
	parsed, err := time.Parse(time.RFC3339, str)
	if err != nil {
		// Try an alternative format if needed, e.g., "2006-01-02 15:04:05-07:00"
		parsed, err = time.Parse("2006-01-02 15:04:05-07:00", str)
		if err != nil {
			return fmt.Errorf("parsing timestamp with time zone: %w", err)
		}
	}

	t.Time = parsed
	return nil
}

// Value implements the driver.Valuer interface to store time.Time as a string
func (t TimeWithZone) Value() (driver.Value, error) {
	return t.Time.Format(time.RFC3339), nil
}

// Snippet represents the snippets table
type Snippet struct {
	ID        string       `db:"id"`
	Content   string       `db:"content"`
	CreatedAt TimeWithZone `db:"created_at"`
}

// Storage handles database operations
type Storage struct {
	db *sql.DB
}

// NewStorage initializes a new SQLite database
func NewStorage(dsn string) (*Storage, error) {
	db, err := sql.Open("sqlite3", dsn)
	if err != nil {
		return nil, fmt.Errorf("opening database: %w", err)
	}

	// Create the snippets table
	_, err = db.Exec(`
		CREATE TABLE IF NOT EXISTS snippets (
			id BLOB PRIMARY KEY,
			content TEXT NOT NULL,
			created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
		)
	`)
	if err != nil {
		return nil, fmt.Errorf("creating table: %w", err)
	}

	return &Storage{db: db}, nil
}

// InsertSnippet inserts a new snippet and returns the created snippet
func (s *Storage) InsertSnippet(ctx context.Context, content string) (*Snippet, error) {
	// Generate a new UUID for the ID
	id, err := uuid.NewRandom()
	if err != nil {
		return nil, fmt.Errorf("generating UUID: %w", err)
	}

	// Convert UUID to binary for storage
	binaryID := id[:]

	// Prepare the snippet struct for scanning
	snippet := &Snippet{
		ID:      id.String(),
		Content: content,
	}

	// Insert and retrieve the created_at timestamp
	err = s.db.QueryRowContext(
		ctx,
		"INSERT INTO snippets (id, content) VALUES (?, ?) RETURNING id, content, created_at",
		binaryID, content,
	).Scan(&snippet.ID, &snippet.Content, &snippet.CreatedAt)
	if err != nil {
		return nil, fmt.Errorf("inserting snippet: %w", err)
	}

	return snippet, nil
}

func main() {
	// Initialize the database (use ":memory:" for in-memory SQLite or a file path)
	storage, err := NewStorage(":memory:")
	if err != nil {
		fmt.Printf("Error initializing storage: %v\n", err)
		return
	}
	defer storage.db.Close()

	// Insert a snippet
	ctx := context.Background()
	snippet, err := storage.InsertSnippet(ctx, "Hello, world!")
	if err != nil {
		fmt.Printf("Error inserting snippet: %v\n", err)
		return
	}

	// Print the inserted snippet
	fmt.Printf("Inserted snippet: ID=%s, Content=%s, CreatedAt=%s\n",
		snippet.ID, snippet.Content, snippet.CreatedAt.Time.Format(time.RFC3339))
}

ljluestc avatar Jun 22 '25 19:06 ljluestc