From b616203db29e422f25c0ab50fa130be52d4f33a4 Mon Sep 17 00:00:00 2001 From: Andrew Steurer <94206073+asteurer@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:47:00 -0500 Subject: [PATCH] feat(mysql): implement mysql in wasip2 Signed-off-by: Andrew Steurer <94206073+asteurer@users.noreply.github.com> --- mysql/mysql.go | 9 +- v3/examples/mysql-outbound/.gitignore | 2 + v3/examples/mysql-outbound/README.md | 30 +++ v3/examples/mysql-outbound/compose.yaml | 24 ++ v3/examples/mysql-outbound/go.mod | 12 + v3/examples/mysql-outbound/go.sum | 4 + v3/examples/mysql-outbound/init.sql | 6 + v3/examples/mysql-outbound/main.go | 58 ++++ v3/examples/mysql-outbound/spin.toml | 19 ++ v3/internal/db/driver.go | 15 ++ v3/mysql/mysql.go | 342 ++++++++++++++++++++++++ 11 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 v3/examples/mysql-outbound/.gitignore create mode 100644 v3/examples/mysql-outbound/README.md create mode 100644 v3/examples/mysql-outbound/compose.yaml create mode 100644 v3/examples/mysql-outbound/go.mod create mode 100644 v3/examples/mysql-outbound/go.sum create mode 100644 v3/examples/mysql-outbound/init.sql create mode 100644 v3/examples/mysql-outbound/main.go create mode 100644 v3/examples/mysql-outbound/spin.toml create mode 100644 v3/internal/db/driver.go create mode 100644 v3/mysql/mysql.go diff --git a/mysql/mysql.go b/mysql/mysql.go index e46cab7..e643712 100644 --- a/mysql/mysql.go +++ b/mysql/mysql.go @@ -1,3 +1,4 @@ +// Package mysql provides access to MySQL within Spin components. package mysql import ( @@ -114,9 +115,8 @@ type rows struct { columns []string columnType []uint8 pos int - len int + numRows int rows [][]any - closed bool } var _ driver.Rows = (*rows)(nil) @@ -132,8 +132,7 @@ func (r *rows) Columns() []string { func (r *rows) Close() error { r.rows = nil r.pos = 0 - r.len = 0 - r.closed = true + r.numRows = 0 return nil } @@ -152,7 +151,7 @@ func (r *rows) Next(dest []driver.Value) error { // 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.len + return r.pos < r.numRows } // NextResultSet advances the driver to the next result set even diff --git a/v3/examples/mysql-outbound/.gitignore b/v3/examples/mysql-outbound/.gitignore new file mode 100644 index 0000000..b565010 --- /dev/null +++ b/v3/examples/mysql-outbound/.gitignore @@ -0,0 +1,2 @@ +main.wasm +.spin/ diff --git a/v3/examples/mysql-outbound/README.md b/v3/examples/mysql-outbound/README.md new file mode 100644 index 0000000..75105fc --- /dev/null +++ b/v3/examples/mysql-outbound/README.md @@ -0,0 +1,30 @@ +# Requirements +- Latest version of [TinyGo](https://tinygo.org/getting-started/) +- Latest version of [Docker](https://docs.docker.com/get-started/get-docker/) + +# Usage + +In a terminal window, use the below command to run MySQL: +```sh +docker compose up -d +``` + +Then, you'll build and run your Spin app: +```sh +spin up --build +``` + +In another terminal window, you can interact with the Spin app: +```sh +curl localhost:3000 +``` + +You should see the output: +```json +[{"ID":1,"Name":"Splodge","Prey":null,"IsFinicky":false},{"ID":2,"Name":"Kiki","Prey":"Cicadas","IsFinicky":false},{"ID":3,"Name":"Slats","Prey":"Temptations","IsFinicky":true},{"ID":4,"Name":"Maya","Prey":"bananas","IsFinicky":true},{"ID":5,"Name":"Copper","Prey":"Foxes","IsFinicky":false}] +``` + +To stop and clean up the MySQL container, run the following: +```sh +docker compose down -v +``` \ No newline at end of file diff --git a/v3/examples/mysql-outbound/compose.yaml b/v3/examples/mysql-outbound/compose.yaml new file mode 100644 index 0000000..6d9c443 --- /dev/null +++ b/v3/examples/mysql-outbound/compose.yaml @@ -0,0 +1,24 @@ +services: + mysql: + image: mysql:8 + container_name: mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: spin_data + MYSQL_USER: spin + MYSQL_PASSWORD: spin + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - mysql_network + +volumes: + mysql_data: + +networks: + mysql_network: + driver: bridge \ No newline at end of file diff --git a/v3/examples/mysql-outbound/go.mod b/v3/examples/mysql-outbound/go.mod new file mode 100644 index 0000000..3e780ed --- /dev/null +++ b/v3/examples/mysql-outbound/go.mod @@ -0,0 +1,12 @@ +module github.com/spinframework/spin-go-sdk/v3/examples/mysql-outbound + +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/mysql-outbound/go.sum b/v3/examples/mysql-outbound/go.sum new file mode 100644 index 0000000..c1ebfdf --- /dev/null +++ b/v3/examples/mysql-outbound/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/mysql-outbound/init.sql b/v3/examples/mysql-outbound/init.sql new file mode 100644 index 0000000..c2d5384 --- /dev/null +++ b/v3/examples/mysql-outbound/init.sql @@ -0,0 +1,6 @@ +CREATE DATABASE IF NOT EXISTS spin_data; +USE spin_data; +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/mysql-outbound/main.go b/v3/examples/mysql-outbound/main.go new file mode 100644 index 0000000..f20e909 --- /dev/null +++ b/v3/examples/mysql-outbound/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + + spinhttp "github.com/spinframework/spin-go-sdk/v3/http" + "github.com/spinframework/spin-go-sdk/v3/mysql" +) + +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) { + + // addr is the environment variable set in `spin.toml` that points to the + // address of the Mysql server. + addr := os.Getenv("DB_URL") + + db := mysql.Open(addr) + defer db.Close() + + if _, err := db.Query("REPLACE INTO pets VALUES (?, 'Maya', ?, ?);", 4, "bananas", true); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if _, err := db.Exec("INSERT INTO pets VALUES (?, ?, ?, ?)", 5, "Copper", "Foxes", false); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + rows, err := db.Query("SELECT * 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 { + fmt.Println(err) + } + pets = append(pets, &pet) + } + json.NewEncoder(w).Encode(pets) + }) +} + +func main() {} diff --git a/v3/examples/mysql-outbound/spin.toml b/v3/examples/mysql-outbound/spin.toml new file mode 100644 index 0000000..433b1a0 --- /dev/null +++ b/v3/examples/mysql-outbound/spin.toml @@ -0,0 +1,19 @@ +spin_manifest_version = 2 + +[application] +name = "go-mysql-outbound-example" +version = "0.1.0" +authors = ["Andrew Steurer <94206073+asteurer@users.noreply.github.com>"] +description = "Using Spin with MySQL" + +[[trigger.http]] +route = "/..." +component = "mysql" + +[component.mysql] +environment = { DB_URL = "mysql://spin:spin@127.0.0.1/spin_data" } +source = "main.wasm" +allowed_outbound_hosts = ["mysql://127.0.0.1"] +[component.mysql.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/mysql/mysql.go b/v3/mysql/mysql.go new file mode 100644 index 0000000..6d1729d --- /dev/null +++ b/v3/mysql/mysql.go @@ -0,0 +1,342 @@ +package mysql + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "io" + "reflect" + + spindb "github.com/spinframework/spin-go-sdk/v3/internal/db" + "github.com/spinframework/spin-go-sdk/v3/internal/fermyon/spin/v2.0.0/mysql" + rdbmstypes "github.com/spinframework/spin-go-sdk/v3/internal/fermyon/spin/v2.0.0/rdbms-types" + "go.bytecodealliance.org/cm" +) + +// Open returns a new connection to the database. +func Open(name string) *sql.DB { + return sql.OpenDB(&connector{name: name}) +} + +type conn struct { + spinConn mysql.Connection +} + +func (c *conn) Close() error { + return nil +} + +func (c *conn) Prepare(query string) (driver.Stmt, error) { + return &stmt{conn: c, query: query}, nil +} + +func (c *conn) Begin() (driver.Tx, error) { + return nil, errors.New("transactions are unsupported by this driver") +} + +type connector struct { + conn *conn + name string +} + +func (d *connector) Connect(_ context.Context) (driver.Conn, error) { + if d.conn != nil { + return d.conn, nil + } + return d.Open(d.name) +} + +func (d *connector) Driver() driver.Driver { + return d +} + +func (d *connector) Open(name string) (driver.Conn, error) { + results := mysql.ConnectionOpen(name) + if results.IsErr() { + return nil, toError(results.Err()) + } + d.conn = &conn{spinConn: *results.OK()} + return d.conn, nil +} + +func (d *connector) Close() error { + if d.conn != nil { + d.conn.Close() + } + return nil +} + +type rows struct { + columns []string + columnType []uint8 + pos int + len int + rows [][]any + closed bool +} + +var _ driver.Rows = (*rows)(nil) +var _ driver.RowsColumnTypeScanType = (*rows)(nil) +var _ driver.RowsNextResultSet = (*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.len = 0 + r.closed = true + 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.len +} + +// 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. +} + +// ColumnTypeScanType return the value type that can be used to scan types into. +func (r *rows) ColumnTypeScanType(index int) reflect.Type { + return colTypeToReflectType(r.columnType[index]) +} + +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 +} + +// 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) { + wasiParams := make([]mysql.ParameterValue, len(args)) + for i, v := range args { + wasiParams[i] = toWasiParameterValue(v) + } + + _, err, isErr := s.conn.spinConn.Execute(s.query, cm.ToList(wasiParams)).Result() + if isErr { + return &result{}, toError(&err) + } + + return &result{}, nil +} + +// Query executes a query that may return rows, such as a SELECT. +func (s *stmt) Query(args []driver.Value) (driver.Rows, error) { + wasiParams := make([]mysql.ParameterValue, len(args)) + for i, v := range args { + wasiParams[i] = toWasiParameterValue(v) + } + + results, err, isErr := s.conn.spinConn.Query(s.query, cm.ToList(wasiParams)).Result() + if isErr { + return nil, toError(&err) + } + + rowLen := results.Rows.Len() + allRows := make([][]any, rowLen) + for rowNum, row := range results.Rows.Slice() { + allRows[rowNum] = toRow(row.Slice()) + } + + cols := results.Columns.Slice() + colNames := make([]string, len(cols)) + colTypes := make([]uint8, len(cols)) + for i, c := range cols { + colNames[i] = c.Name + colTypes[i] = uint8(c.DataType) + } + + rows := &rows{ + columns: colNames, + columnType: colTypes, + rows: allRows, + len: int(rowLen), + } + return rows, nil +} + +func toWasiParameterValue(x any) mysql.ParameterValue { + switch v := x.(type) { + case bool: + return rdbmstypes.ParameterValueBoolean(v) + case int8: + return rdbmstypes.ParameterValueInt8(v) + case int16: + return rdbmstypes.ParameterValueInt16(v) + case int32: + return rdbmstypes.ParameterValueInt32(v) + case int64: + return rdbmstypes.ParameterValueInt64(v) + case int: + return rdbmstypes.ParameterValueInt64(int64(v)) + case uint8: + return rdbmstypes.ParameterValueUint8(v) + case uint16: + return rdbmstypes.ParameterValueUint16(v) + case uint32: + return rdbmstypes.ParameterValueUint32(v) + case uint64: + return rdbmstypes.ParameterValueUint64(v) + case float32: + return rdbmstypes.ParameterValueFloating32(v) + case float64: + return rdbmstypes.ParameterValueFloating64(v) + case string: + return rdbmstypes.ParameterValueStr(v) + case []byte: + return rdbmstypes.ParameterValueBinary(cm.ToList([]uint8(v))) + case nil: + return rdbmstypes.ParameterValueDbNull() + default: + panic("unknown value type") + } +} + +func toError(err *mysql.Error) error { + if err == nil { + return nil + } + + switch err.String() { + case "bad-parameter": + return errors.New(*err.BadParameter()) + case "connection-failed": + return errors.New(*err.ConnectionFailed()) + case "query-failed": + return errors.New(*err.QueryFailed()) + case "value-conversion-failed": + return errors.New(*err.ValueConversionFailed()) + default: + // TODO: not sure if using "Other" as the default is appropriate + return errors.New(*err.Other()) + } +} + +func toRow(row []rdbmstypes.DbValue) []any { + result := make([]any, len(row)) + for i, v := range row { + switch v.String() { + case "boolean": + result[i] = *v.Boolean() + case "int8": + result[i] = *v.Int8() + case "int16": + result[i] = *v.Int16() + case "int32": + result[i] = *v.Int32() + case "int64": + result[i] = *v.Int64() + case "uint8": + result[i] = *v.Uint8() + case "uint16": + result[i] = *v.Uint16() + case "uint32": + result[i] = *v.Uint32() + case "uint64": + result[i] = *v.Uint64() + case "floating32": + result[i] = *v.Floating32() + case "floating64": + result[i] = *v.Floating64() + case "str": + result[i] = *v.Str() + case "binary": + result[i] = *v.Binary() + case "db-null": + result[i] = nil + default: + panic("unknown value type") + } + } + + return result +} + +// 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 colTypeToReflectType(typ uint8) reflect.Type { + switch typ { + case uint8(rdbmstypes.DbDataTypeBoolean): + return reflect.TypeOf(false) + case uint8(rdbmstypes.DbDataTypeInt8): + return reflect.TypeOf(int8(0)) + case uint8(rdbmstypes.DbDataTypeInt16): + return reflect.TypeOf(int16(0)) + case uint8(rdbmstypes.DbDataTypeInt32): + return reflect.TypeOf(int32(0)) + case uint8(rdbmstypes.DbDataTypeInt64): + return reflect.TypeOf(int64(0)) + case uint8(rdbmstypes.DbDataTypeUint8): + return reflect.TypeOf(uint8(0)) + case uint8(rdbmstypes.DbDataTypeUint16): + return reflect.TypeOf(uint16(0)) + case uint8(rdbmstypes.DbDataTypeUint32): + return reflect.TypeOf(uint32(0)) + case uint8(rdbmstypes.DbDataTypeUint64): + return reflect.TypeOf(uint64(0)) + case uint8(rdbmstypes.DbDataTypeStr): + return reflect.TypeOf("") + case uint8(rdbmstypes.DbDataTypeBinary): + return reflect.TypeOf(new([]byte)) + case uint8(rdbmstypes.DbDataTypeOther): + return reflect.TypeOf(new(any)).Elem() + } + panic("invalid db column type of " + string(typ)) +}