Skip to content

feat(sqlite): implement sqlite in wasip2 #25

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: wasip2
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions v3/examples/sqlite/db/pets.sql
Original file line number Diff line number Diff line change
@@ -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);
12 changes: 12 additions & 0 deletions v3/examples/sqlite/go.mod
Original file line number Diff line number Diff line change
@@ -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 => ../../
4 changes: 4 additions & 0 deletions v3/examples/sqlite/go.sum
Original file line number Diff line number Diff line change
@@ -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=
50 changes: 50 additions & 0 deletions v3/examples/sqlite/main.go
Original file line number Diff line number Diff line change
@@ -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() {}
19 changes: 19 additions & 0 deletions v3/examples/sqlite/spin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
spin_manifest_version = 2

[application]
name = "sqlite-example"
version = "0.1.0"
authors = ["Fermyon Engineering <[email protected]>"]
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"
Copy link
Contributor

Choose a reason for hiding this comment

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

Will the $(go list ...) work on Windows?

Copy link
Member

Choose a reason for hiding this comment

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

hmm, go list should work, but not sure about $(go list...). I had opened a ticket ydnar/wasi-http-go#22 to check with other folks if there is a better way to do this.

watch = ["**/*.go", "go.mod"]
15 changes: 15 additions & 0 deletions v3/internal/db/driver.go
Original file line number Diff line number Diff line change
@@ -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
}
27 changes: 27 additions & 0 deletions v3/sqlite/doc.go
Original file line number Diff line number Diff line change
@@ -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
255 changes: 255 additions & 0 deletions v3/sqlite/sqlite.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this now TODONE?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call, It's not. For some reason blob columns are being returned as text. I'm still tracing it.

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())
}