diff --git a/context.go b/context.go index 6c254d1..4fcda56 100644 --- a/context.go +++ b/context.go @@ -130,7 +130,8 @@ func (ctx Context) ResultNull() { // // https://sqlite.org/c3ref/result_blob.html func (ctx Context) ResultTime(value time.Time, format TimeFormat) { - if format == TimeFormatDefault { + switch format { + case TimeFormatDefault, TimeFormatAuto, time.RFC3339Nano: ctx.resultRFC3339Nano(value) return } diff --git a/driver/driver.go b/driver/driver.go index c4080e4..575b87d 100644 --- a/driver/driver.go +++ b/driver/driver.go @@ -8,6 +8,8 @@ // // The data source name for "sqlite3" databases can be a filename or a "file:" [URI]. // +// # Default transaction mode +// // The [TRANSACTION] mode can be specified using "_txlock": // // sql.Open("sqlite3", "file:demo.db?_txlock=immediate") @@ -18,6 +20,8 @@ // - a [serializable] transaction is always "immediate"; // - a [read-only] transaction is always "deferred". // +// # Working with time +// // The time encoding/decoding format can be specified using "_timefmt": // // sql.Open("sqlite3", "file:demo.db?_timefmt=sqlite") @@ -27,6 +31,28 @@ // - "sqlite" encodes as SQLite and decodes any [format] supported by SQLite; // - "rfc3339" encodes and decodes RFC 3339 only. // +// If you encode as RFC 3339 (the default), +// consider using the TIME [collating sequence] to produce a time-ordered sequence. +// +// To scan values in other formats, [sqlite3.TimeFormat.Scanner] may be helpful. +// To bind values in other formats, [sqlite3.TimeFormat.Encode] them before binding. +// +// When using a custom time struct, you'll have to implement +// [database/sql/driver.Valuer] and [database/sql.Scanner]. +// +// The Value method should ideally serialise to a time [format] supported by SQLite. +// This ensures SQL date and time functions work as they should, +// and that your schema works with other SQLite tools. +// [sqlite3.TimeFormat.Encode] may help. +// +// The Scan method needs to take into account that the value it receives can be of differing types. +// It can already be a [time.Time], if the driver decoded the value according to "_timefmt" rules. +// Or it can be a: string, int64, float64, []byte, nil, +// depending on the column type and what whoever wrote the value. +// [sqlite3.TimeFormat.Decode] may help. +// +// # Setting PRAGMAs +// // [PRAGMA] statements can be specified using "_pragma": // // sql.Open("sqlite3", "file:demo.db?_pragma=busy_timeout(10000)") @@ -34,7 +60,8 @@ // If no PRAGMAs are specified, a busy timeout of 1 minute is set. // // Order matters: -// busy timeout and locking mode should be the first PRAGMAs set, in that order. +// encryption keys, busy timeout and locking mode should be the first PRAGMAs set, +// in that order. // // [URI]: https://sqlite.org/uri.html // [PRAGMA]: https://sqlite.org/pragma.html @@ -43,6 +70,7 @@ // [serializable]: https://pkg.go.dev/database/sql#TxOptions // [read-only]: https://pkg.go.dev/database/sql#TxOptions // [format]: https://sqlite.org/lang_datefunc.html#time_values +// [collating sequence]: https://sqlite.org/datatype3.html#collating_sequences package driver import ( @@ -584,7 +612,8 @@ func (r *rows) Next(dest []driver.Value) error { } func (r *rows) decodeTime(i int, v any) (_ time.Time, ok bool) { - if r.tmRead == sqlite3.TimeFormatDefault { + switch r.tmRead { + case sqlite3.TimeFormatDefault, time.RFC3339Nano: // handled by maybeTime return } diff --git a/driver/example_test.go b/driver/example_test.go index 9698e65..2d3b19a 100644 --- a/driver/example_test.go +++ b/driver/example_test.go @@ -6,12 +6,16 @@ package driver_test import ( "database/sql" + "database/sql/driver" "fmt" "log" "os" + "time" + "github.com/ncruces/go-sqlite3" _ "github.com/ncruces/go-sqlite3/driver" _ "github.com/ncruces/go-sqlite3/embed" + _ "github.com/ncruces/go-sqlite3/vfs/memdb" ) var db *sql.DB @@ -149,3 +153,129 @@ func addAlbum(alb Album) (int64, error) { } return id, nil } + +func Example_customTime() { + db, err := sql.Open("sqlite3", "file:/time.db?vfs=memdb") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + _, err = db.Exec(` + CREATE TABLE data ( + id INTEGER PRIMARY KEY, + date_time TEXT + ) STRICT; + `) + if err != nil { + log.Fatal(err) + } + + // This one will be returned as string to [sql.Scanner] because it doesn't + // pass the driver's round-trip test when it tries to figure out if it's + // a time. 2009-11-17T20:34:58.650Z goes in, but parsing and formatting + // it with [time.RFC3338Nano] results in 2009-11-17T20:34:58.65Z. Though + // the times are identical, the trailing zero is lost in the string + // representation so the driver considers the conversion unsuccesful. + c1 := CustomTime{time.Date( + 2009, 11, 17, 20, 34, 58, 650000000, time.UTC)} + + // Store our custom time in the database. + _, err = db.Exec(`INSERT INTO data (date_time) VALUES(?)`, c1) + if err != nil { + log.Fatal(err) + } + + var strc1 string + // Retrieve it as a string, the result of Value(). + err = db.QueryRow(` + SELECT date_time + FROM data + WHERE id = last_insert_rowid() + `).Scan(&strc1) + if err != nil { + log.Fatal(err) + } + fmt.Println("in db:", strc1) + + var resc1 CustomTime + // Retrieve it as our custom time type, going through Scan(). + err = db.QueryRow(` + SELECT date_time + FROM data + WHERE id = last_insert_rowid() + `).Scan(&resc1) + if err != nil { + log.Fatal(err) + } + fmt.Println("custom time:", resc1) + + // This one will be returned as [time.Time] to [sql.Scanner] because it does + // pass the driver's round-trip test when it tries to figure out if it's + // a time. 2009-11-17T20:34:58.651Z goes in, and parsing and formatting + // it with [time.RFC3339Nano] results in 2009-11-17T20:34:58.651Z. + c2 := CustomTime{time.Date( + 2009, 11, 17, 20, 34, 58, 651000000, time.UTC)} + // Store our custom time in the database. + _, err = db.Exec(`INSERT INTO data (date_time) VALUES(?)`, c2) + if err != nil { + log.Fatal(err) + } + + var strc2 string + // Retrieve it as a string, the result of Value(). + err = db.QueryRow(` + SELECT date_time + FROM data + WHERE id = last_insert_rowid() + `).Scan(&strc2) + if err != nil { + log.Fatal(err) + } + fmt.Println("in db:", strc2) + + var resc2 CustomTime + // Retrieve it as our custom time type, going through Scan(). + err = db.QueryRow(` + SELECT date_time + FROM data + WHERE id = last_insert_rowid() + `).Scan(&resc2) + if err != nil { + log.Fatal(err) + } + fmt.Println("custom time:", resc2) + // Output: + // in db: 2009-11-17T20:34:58.650Z + // scan type string: 2009-11-17T20:34:58.650Z + // custom time: 2009-11-17 20:34:58.65 +0000 UTC + // in db: 2009-11-17T20:34:58.651Z + // scan type time: 2009-11-17 20:34:58.651 +0000 UTC + // custom time: 2009-11-17 20:34:58.651 +0000 UTC +} + +type CustomTime struct{ time.Time } + +func (c CustomTime) Value() (driver.Value, error) { + return sqlite3.TimeFormat7TZ.Encode(c.UTC()), nil +} + +func (c *CustomTime) Scan(value any) error { + switch v := value.(type) { + case nil: + *c = CustomTime{time.Time{}} + case time.Time: + fmt.Println("scan type time:", v) + *c = CustomTime{v} + case string: + fmt.Println("scan type string:", v) + t, err := sqlite3.TimeFormat7TZ.Decode(v) + if err != nil { + return err + } + *c = CustomTime{t} + default: + panic("unsupported value type") + } + return nil +} diff --git a/driver/json_test.go b/driver/json_test.go index f5b5c18..7581f24 100644 --- a/driver/json_test.go +++ b/driver/json_test.go @@ -11,7 +11,7 @@ import ( ) func Example_json() { - db, err := driver.Open("file:/test.db?vfs=memdb") + db, err := driver.Open("file:/json.db?vfs=memdb") if err != nil { log.Fatal(err) } diff --git a/driver/savepoint_test.go b/driver/savepoint_test.go index 9939b69..bac0f73 100644 --- a/driver/savepoint_test.go +++ b/driver/savepoint_test.go @@ -10,7 +10,7 @@ import ( ) func ExampleSavepoint() { - db, err := driver.Open("file:/test.db?vfs=memdb") + db, err := driver.Open("file:/svpt.db?vfs=memdb") if err != nil { log.Fatal(err) } diff --git a/go.work.sum b/go.work.sum index 3fc3606..76ac94b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -4,5 +4,6 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= diff --git a/stmt.go b/stmt.go index 6fb8368..8e6ad2c 100644 --- a/stmt.go +++ b/stmt.go @@ -311,7 +311,8 @@ func (s *Stmt) BindNull(param int) error { // // https://sqlite.org/c3ref/bind_blob.html func (s *Stmt) BindTime(param int, value time.Time, format TimeFormat) error { - if format == TimeFormatDefault { + switch format { + case TimeFormatDefault, TimeFormatAuto, time.RFC3339Nano: return s.bindRFC3339Nano(param, value) } switch v := format.Encode(value).(type) {