From 27c9b9d87071f573274620e8d6f0021f6b3c4adc Mon Sep 17 00:00:00 2001 From: Adam Reese Date: Thu, 31 Jul 2025 21:18:01 -0700 Subject: [PATCH] feat(sqlite): implement sqlite in wasip2 Signed-off-by: Adam Reese --- v3/examples/sqlite/db/pets.sql | 4 + v3/examples/sqlite/go.mod | 12 ++ v3/examples/sqlite/go.sum | 4 + v3/examples/sqlite/main.go | 50 +++++++ v3/examples/sqlite/spin.toml | 19 +++ v3/internal/db/driver.go | 15 ++ v3/sqlite/doc.go | 27 ++++ v3/sqlite/sqlite.go | 255 +++++++++++++++++++++++++++++++++ 8 files changed, 386 insertions(+) create mode 100644 v3/examples/sqlite/db/pets.sql create mode 100644 v3/examples/sqlite/go.mod create mode 100644 v3/examples/sqlite/go.sum create mode 100644 v3/examples/sqlite/main.go create mode 100644 v3/examples/sqlite/spin.toml create mode 100644 v3/internal/db/driver.go create mode 100644 v3/sqlite/doc.go create mode 100644 v3/sqlite/sqlite.go diff --git a/v3/examples/sqlite/db/pets.sql b/v3/examples/sqlite/db/pets.sql new file mode 100644 index 0000000..bbf91d6 --- /dev/null +++ b/v3/examples/sqlite/db/pets.sql @@ -0,0 +1,4 @@ +CREATE TABLE pets (id INT PRIMARY KEY, name VARCHAR(100) NOT NULL, prey VARCHAR(100), is_finicky BOOL NOT NULL); +INSERT INTO pets VALUES (1, 'Splodge', NULL, false); +INSERT INTO pets VALUES (2, 'Kiki', 'Cicadas', false); +INSERT INTO pets VALUES (3, 'Slats', 'Temptations', true); diff --git a/v3/examples/sqlite/go.mod b/v3/examples/sqlite/go.mod new file mode 100644 index 0000000..9a2bf8a --- /dev/null +++ b/v3/examples/sqlite/go.mod @@ -0,0 +1,12 @@ +module github.com/spinframework/spin-go-sdk/v3/examples/sqlite + +go 1.24 + +require github.com/spinframework/spin-go-sdk/v3 v3.0.0 + +require ( + github.com/julienschmidt/httprouter v1.3.0 // indirect + go.bytecodealliance.org/cm v0.2.2 // indirect +) + +replace github.com/spinframework/spin-go-sdk/v3 => ../../ diff --git a/v3/examples/sqlite/go.sum b/v3/examples/sqlite/go.sum new file mode 100644 index 0000000..c1ebfdf --- /dev/null +++ b/v3/examples/sqlite/go.sum @@ -0,0 +1,4 @@ +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +go.bytecodealliance.org/cm v0.2.2 h1:M9iHS6qs884mbQbIjtLX1OifgyPG9DuMs2iwz8G4WQA= +go.bytecodealliance.org/cm v0.2.2/go.mod h1:JD5vtVNZv7sBoQQkvBvAAVKJPhR/bqBH7yYXTItMfZI= diff --git a/v3/examples/sqlite/main.go b/v3/examples/sqlite/main.go new file mode 100644 index 0000000..98f4403 --- /dev/null +++ b/v3/examples/sqlite/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "net/http" + + spinhttp "github.com/spinframework/spin-go-sdk/v3/http" + "github.com/spinframework/spin-go-sdk/v3/sqlite" +) + +type Pet struct { + ID int64 + Name string + Prey *string // nullable field must be a pointer + IsFinicky bool +} + +func init() { + spinhttp.Handle(func(w http.ResponseWriter, r *http.Request) { + db := sqlite.Open("default") + defer db.Close() + + _, err := db.Query("REPLACE INTO pets (id, name, prey, is_finicky) VALUES (4, 'Maya', ?, false);", "bananas") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + rows, err := db.Query("SELECT id, name, prey, is_finicky FROM pets") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var pets []*Pet + for rows.Next() { + var pet Pet + if err := rows.Scan(&pet.ID, &pet.Name, &pet.Prey, &pet.IsFinicky); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + pets = append(pets, &pet) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(pets) + }) +} + +func main() {} diff --git a/v3/examples/sqlite/spin.toml b/v3/examples/sqlite/spin.toml new file mode 100644 index 0000000..c3746bd --- /dev/null +++ b/v3/examples/sqlite/spin.toml @@ -0,0 +1,19 @@ +spin_manifest_version = 2 + +[application] +name = "sqlite-example" +version = "0.1.0" +authors = ["Fermyon Engineering "] +description = "" + +[[trigger.http]] +route = "/..." +component = "sqlite" + +[component.sqlite] +source = "main.wasm" +allowed_outbound_hosts = [] +sqlite_databases = ["default"] +[component.sqlite.build] +command = "tinygo build -target=wasip2 --wit-package $(go list -mod=readonly -m -f '{{.Dir}}' github.com/spinframework/spin-go-sdk/v3)/wit --wit-world http-trigger -gc=leaking -o main.wasm main.go" +watch = ["**/*.go", "go.mod"] diff --git a/v3/internal/db/driver.go b/v3/internal/db/driver.go new file mode 100644 index 0000000..e1c9905 --- /dev/null +++ b/v3/internal/db/driver.go @@ -0,0 +1,15 @@ +package db + +import "database/sql/driver" + +// GlobalParameterConverter is a global valueConverter instance to convert parameters. +var GlobalParameterConverter = &valueConverter{} + +var _ driver.ValueConverter = (*valueConverter)(nil) + +// valueConverter is a no-op value converter. +type valueConverter struct{} + +func (c *valueConverter) ConvertValue(v any) (driver.Value, error) { + return driver.Value(v), nil +} diff --git a/v3/sqlite/doc.go b/v3/sqlite/doc.go new file mode 100644 index 0000000..739f29c --- /dev/null +++ b/v3/sqlite/doc.go @@ -0,0 +1,27 @@ +// Package sqlite provides an interface to sqlite database stores within Spin +// components. +// +// This package is implemented as a driver that conforms to the built-in +// database/sql interface. +// +// db := sqlite.Open("default") +// defer db.Close() +// +// s, err := db.Prepare("REPLACE INTO pets VALUES (4, 'Maya', ?, false);") +// // if err != nil { ... } +// +// _, err = s.Query("bananas") +// // if err != nil { ... } +// +// rows, err := db.Query("SELECT * FROM pets") +// // if err != nil { ... } +// +// var pets []*Pet +// for rows.Next() { +// var pet Pet +// if err := rows.Scan(&pet.ID, &pet.Name, &pet.Prey, &pet.IsFinicky); err != nil { +// ... +// } +// pets = append(pets, &pet) +// } +package sqlite diff --git a/v3/sqlite/sqlite.go b/v3/sqlite/sqlite.go new file mode 100644 index 0000000..6746ac1 --- /dev/null +++ b/v3/sqlite/sqlite.go @@ -0,0 +1,255 @@ +package sqlite + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + + spindb "github.com/spinframework/spin-go-sdk/v3/internal/db" + "github.com/spinframework/spin-go-sdk/v3/internal/fermyon/spin/v2.0.0/sqlite" + "go.bytecodealliance.org/cm" +) + +// Open returns a new connection to the database. +func Open(name string) *sql.DB { + return sql.OpenDB(&connector{name: name}) +} + +// conn represents a database connection. +type conn struct { + spinConn sqlite.Connection +} + +// Close the connection. +func (c *conn) Close() error { + return nil +} + +// Prepare returns a prepared statement, bound to this connection. +func (c *conn) Prepare(query string) (driver.Stmt, error) { + return &stmt{conn: c, query: query}, nil +} + +// Begin isn't supported. +func (c *conn) Begin() (driver.Tx, error) { + return nil, errors.New("transactions are unsupported by this driver") +} + +// connector implements driver.Connector. +type connector struct { + conn *conn + name string +} + +// Connect returns a connection to the database. +func (d *connector) Connect(_ context.Context) (driver.Conn, error) { + if d.conn != nil { + return d.conn, nil + } + return d.Open(d.name) +} + +// Driver returns the underlying Driver of the Connector. +func (d *connector) Driver() driver.Driver { + return d +} + +// Open returns a new connection to the database. +func (d *connector) Open(name string) (driver.Conn, error) { + results := sqlite.ConnectionOpen(name) + if results.IsErr() { + return nil, toError(results.Err()) + } + d.conn = &conn{ + spinConn: *results.OK(), + } + return d.conn, nil +} + +// Close closes the connection to the database. +func (d *connector) Close() error { + if d.conn != nil { + d.conn.Close() + } + return nil +} + +type rows struct { + columns []string + pos int + numRows int + rows [][]any +} + +var _ driver.Rows = (*rows)(nil) + +// Columns return column names. +func (r *rows) Columns() []string { + return r.columns +} + +// Close closes the rows iterator. +func (r *rows) Close() error { + r.rows = nil + r.pos = 0 + r.numRows = 0 + return nil +} + +// Next moves the cursor to the next row. +func (r *rows) Next(dest []driver.Value) error { + if !r.HasNextResultSet() { + return io.EOF + } + for i := 0; i != len(r.columns); i++ { + dest[i] = driver.Value(r.rows[r.pos][i]) + } + r.pos++ + return nil +} + +// HasNextResultSet is called at the end of the current result set and +// reports whether there is another result set after the current one. +func (r *rows) HasNextResultSet() bool { + return r.pos < r.numRows +} + +// NextResultSet advances the driver to the next result set even +// if there are remaining rows in the current result set. +// +// NextResultSet should return io.EOF when there are no more result sets. +func (r *rows) NextResultSet() error { + if r.HasNextResultSet() { + r.pos++ + return nil + } + return io.EOF // Per interface spec. +} + +type stmt struct { + conn *conn + query string +} + +var _ driver.Stmt = (*stmt)(nil) +var _ driver.ColumnConverter = (*stmt)(nil) + +// Close closes the statement. +func (s *stmt) Close() error { + return nil +} + +// NumInput returns the number of placeholder parameters. +func (s *stmt) NumInput() int { + // Golang sql won't sanity check argument counts before Query. + return -1 +} + +func toRow(row []sqlite.Value) []any { + ret := make([]any, len(row)) + for i, v := range row { + switch v.String() { + case "integer": + ret[i] = *v.Integer() + case "real": + ret[i] = *v.Real() + case "text": + ret[i] = *v.Text() + case "blob": + // TODO: check this + ret[i] = *v.Blob() + case "null": + ret[i] = nil + default: + panic("unknown value type") + } + } + return ret +} + +func toWasiValue(x any) sqlite.Value { + switch v := x.(type) { + case int: + return sqlite.ValueInteger(int64(v)) + case int64: + return sqlite.ValueInteger(v) + case float64: + return sqlite.ValueReal(v) + case string: + return sqlite.ValueText(v) + case []byte: + return sqlite.ValueBlob(cm.ToList([]uint8(v))) + case nil: + return sqlite.ValueNull() + default: + panic("unknown value type") + } +} + +// Query executes a query that may return rows, such as a SELECT. +func (s *stmt) Query(args []driver.Value) (driver.Rows, error) { + params := make([]sqlite.Value, len(args)) + for i := range args { + params[i] = toWasiValue(args[i]) + } + results, err, isErr := s.conn.spinConn.Execute(s.query, cm.ToList(params)).Result() + if isErr { + return nil, toError(&err) + } + + cols := results.Columns.Slice() + + rowLen := results.Rows.Len() + allrows := make([][]any, rowLen) + for rownum, row := range results.Rows.Slice() { + allrows[rownum] = toRow(row.Values.Slice()) + } + rows := &rows{ + columns: cols, + rows: allrows, + numRows: int(rowLen), + } + return rows, nil +} + +// Exec executes a query that doesn't return rows, such as an INSERT or +// UPDATE. +func (s *stmt) Exec(args []driver.Value) (driver.Result, error) { + params := make([]sqlite.Value, len(args)) + for i := range args { + params[i] = toWasiValue(args[i]) + } + _, err, isErr := s.conn.spinConn.Execute(s.query, cm.ToList(params)).Result() + if isErr { + return nil, toError(&err) + } + return &result{}, nil +} + +// ColumnConverter returns GlobalParameterConverter to prevent using driver.DefaultParameterConverter. +func (s *stmt) ColumnConverter(_ int) driver.ValueConverter { + return spindb.GlobalParameterConverter +} + +type result struct{} + +func (r result) LastInsertId() (int64, error) { + return -1, errors.New("LastInsertId is unsupported by this driver") +} + +func (r result) RowsAffected() (int64, error) { + return -1, errors.New("RowsAffected is unsupported by this driver") +} + +func toError(err *sqlite.Error) error { + if err == nil { + return nil + } + if err.String() == "io" { + return fmt.Errorf("io: %s", *err.IO()) + } + return errors.New(err.String()) +}