diff --git a/.gitignore b/.gitignore index 3e7241e..f45c3d1 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ internal/examples/counter/counter internal/examples/countercomp/countercomp internal/examples/greeter/greeter internal/examples/livereload/livereload +internal/examples/picocss/picocss internal/examples/plugins/plugins internal/examples/realtimechart/realtimechart -internal/examples/picocss/picocss \ No newline at end of file +internal/examples/shakespeare/shakespeare diff --git a/go.mod b/go.mod index c6a334b..c98d73a 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ go 1.25.4 require maragu.dev/gomponents v1.2.0 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/fsnotify/fsnotify v1.9.0 github.com/go-via/via-plugin-picocss v0.1.0 + github.com/mattn/go-sqlite3 v1.14.32 github.com/starfederation/datastar-go v1.0.3 github.com/stretchr/testify v1.10.0 ) diff --git a/go.sum b/go.sum index 5f0bef5..3934bd1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg= github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= @@ -12,6 +14,7 @@ github.com/go-via/via-plugin-picocss v0.1.0 h1:ytVtBlfYBhidos5ub4a8liYqadz1AkeHh github.com/go-via/via-plugin-picocss v0.1.0/go.mod h1:5LEnLE7q8YfYY7jtH/TLPvfquB7Qt9WZ7TbKrskUW+0= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= @@ -21,6 +24,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/examples/shakespeare/README.md b/internal/examples/shakespeare/README.md new file mode 100644 index 0000000..42ac4c0 --- /dev/null +++ b/internal/examples/shakespeare/README.md @@ -0,0 +1,9 @@ +## Shake.db + +This DB was constructed following this excellent talk: https://www.youtube.com/watch?v=RqubKSF3wig + +## Running + +It's important to pass the fts5 build tag, or you'll get `no such module: fts5`. + +Run with: `go run -tags fts5 main.go` \ No newline at end of file diff --git a/internal/examples/shakespeare/main.go b/internal/examples/shakespeare/main.go new file mode 100644 index 0000000..4e5d85f --- /dev/null +++ b/internal/examples/shakespeare/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "time" + + "github.com/go-via/via" + "github.com/go-via/via/h" + _ "github.com/mattn/go-sqlite3" +) + +type DataSource interface { + Open() + Query(str string) (*sql.Rows, error) + Close() error +} + +type ShakeDB struct { + db *sql.DB + findByTextStmt *sql.Stmt +} + +func (shakeDB *ShakeDB) Prepare() { + db, err := sql.Open("sqlite3", "shake.db") + if err != nil { + log.Fatal(err) + } + stmt, err := db.Prepare(`select play,player,plays.text + from playsearch inner join plays on playsearch.playsrowid=plays.rowid where playsearch.text match ? + order by plays.play, plays.player limit 200;`) + if err != nil { + log.Fatal(err) + } + shakeDB.db = db + shakeDB.findByTextStmt = stmt +} + +func (shakeDB *ShakeDB) Query(str string) (*sql.Rows, error) { + return shakeDB.findByTextStmt.Query(str) +} + +func (shakeDB *ShakeDB) Close() { + if shakeDB.db != nil { + shakeDB.db.Close() + shakeDB.db = nil + } +} + +func main() { + v := via.New() + + v.Config(via.Options{ + DevMode: true, + DocumentTitle: "Search", + LogLvl: via.LogLevelWarn, + }) + + v.AppendToHead( + h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")), + h.StyleEl(h.Raw(".no-wrap { white-space: nowrap; }")), + ) + shakeDB := &ShakeDB{} + shakeDB.Prepare() + defer shakeDB.Close() + + v.Page("/", func(c *via.Context) { + query := c.Signal("whether tis") + var rowsTable H + runQuery := func() { + qry := query.String() + start := time.Now() + rows, error := shakeDB.Query(qry) + fmt.Println("query ", qry, "took", time.Since(start)) + if error != nil { + rowsTable = h.Div(h.Text("Error: " + error.Error())) + } else { + table, err := RenderTable(rows, []string{"no-wrap", "no-wrap", ""}) + if err != nil { + rowsTable = h.Div(h.Text("Error: " + err.Error())) + } else { + rowsTable = table + } + } + } + runQueryAction := c.Action(func() { + runQuery() + c.Sync() + }) + runQuery() + c.View(func() h.H { + return h.Div( + h.H2(h.Text("Search")), h.FieldSet( + h.Attr("role", "group"), + h.Input( + h.Type("text"), + query.Bind(), + h.Attr("autofocus"), + runQueryAction.OnKeyDown("Enter"), + ), + h.Button(h.Text("Search"), runQueryAction.OnClick())), + rowsTable, + ) + }) + }) + + v.Start() +} diff --git a/internal/examples/shakespeare/rowsToHTML.go b/internal/examples/shakespeare/rowsToHTML.go new file mode 100644 index 0000000..f8dd6d6 --- /dev/null +++ b/internal/examples/shakespeare/rowsToHTML.go @@ -0,0 +1,77 @@ +package main + +import ( + "database/sql" + "fmt" + + "github.com/go-via/via/h" +) + +type H = h.H + +func valueToString(v any) string { + if v == nil { + return "" + } + if b, ok := v.([]byte); ok { + return string(b) + } + return fmt.Sprint(v) +} + +// RenderTable takes sql.Rows and an array of CSS class names for each column. +// Returns a complete HTML table as a gomponent. +func RenderTable(rows *sql.Rows, columnClasses []string) (H, error) { + cols, err := rows.Columns() + if err != nil { + return nil, err + } + + headerCells := make([]h.H, len(cols)) + for i, col := range cols { + headerCells[i] = h.Th(h.Attr("scope", "col"), h.Text(col)) + } + thead := h.THead(h.Tr(headerCells...)) + + var bodyRows []h.H + for rows.Next() { + values := make([]any, len(cols)) + scanArgs := make([]any, len(cols)) + for i := range values { + scanArgs[i] = &values[i] + } + + if err := rows.Scan(scanArgs...); err != nil { + return nil, err + } + + cells := make([]h.H, len(values)) + if len(values) > 0 { + var thAttrs []h.H + thAttrs = append(thAttrs, h.Attr("scope", "row")) + if len(columnClasses) > 0 && columnClasses[0] != "" { + thAttrs = append(thAttrs, h.Class(columnClasses[0])) + } + thAttrs = append(thAttrs, h.Text(valueToString(values[0]))) + cells[0] = h.Th(thAttrs...) + + for i := 1; i < len(values); i++ { + var tdAttrs []h.H + if i < len(columnClasses) && columnClasses[i] != "" { + tdAttrs = append(tdAttrs, h.Class(columnClasses[i])) + } + tdAttrs = append(tdAttrs, h.Text(valueToString(values[i]))) + cells[i] = h.Td(tdAttrs...) + } + } + + bodyRows = append(bodyRows, h.Tr(cells...)) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + tbody := h.TBody(bodyRows...) + return h.Table(thead, tbody), nil +} diff --git a/internal/examples/shakespeare/rowsToHTML_test.go b/internal/examples/shakespeare/rowsToHTML_test.go new file mode 100644 index 0000000..788126e --- /dev/null +++ b/internal/examples/shakespeare/rowsToHTML_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" +) + +func TestRenderTable(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + columns := []string{"play", "player", "text"} + mock.ExpectQuery("SELECT"). + WillReturnRows( + sqlmock.NewRows(columns). + AddRow("Hamlet", "Hamlet", "To be or not to be"). + AddRow("Macbeth", "Macbeth", "Out, out brief candle!"). + AddRow("Romeo and Juliet", "Juliet", "O Romeo, Romeo!"), + ) + + rows, err := db.Query("SELECT play, player, text FROM plays") + assert.NoError(t, err) + defer rows.Close() + + table, err := RenderTable(rows, []string{"no-wrap", "no-wrap", ""}) + assert.NoError(t, err) + assert.NotNil(t, table) + + var buf bytes.Buffer + err = table.Render(&buf) + assert.NoError(t, err) + + html := buf.String() + + assert.Contains(t, html, "