Unified Storage: in SQL template, also handle output data, improve API, examples and docs (#87560)

* preview of work so far

* stylistic improvements

* fix linters

* remove golden tests, they may cause the system to be too rigid to changes

* remove unnecessary code for golden tests

* remove white space mangling in Execute

* also handle output data, improve API, examples and docs

* add helper methods

* fix interface
pull/87976/head
Diego Augusto Molina 1 year ago committed by GitHub
parent c747e344bf
commit cbcd945251
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      pkg/services/store/entity/sqlstash/sqltemplate/args.go
  2. 6
      pkg/services/store/entity/sqlstash/sqltemplate/args_test.go
  3. 4
      pkg/services/store/entity/sqlstash/sqltemplate/dialect.go
  4. 9
      pkg/services/store/entity/sqlstash/sqltemplate/dialect_mysql.go
  5. 7
      pkg/services/store/entity/sqlstash/sqltemplate/dialect_mysql_test.go
  6. 9
      pkg/services/store/entity/sqlstash/sqltemplate/dialect_postgresql.go
  7. 4
      pkg/services/store/entity/sqlstash/sqltemplate/dialect_postgresql_test.go
  8. 9
      pkg/services/store/entity/sqlstash/sqltemplate/dialect_sqlite.go
  9. 7
      pkg/services/store/entity/sqlstash/sqltemplate/dialect_sqlite_test.go
  10. 2
      pkg/services/store/entity/sqlstash/sqltemplate/dialect_test.go
  11. 189
      pkg/services/store/entity/sqlstash/sqltemplate/example_test.go
  12. 22
      pkg/services/store/entity/sqlstash/sqltemplate/into.go
  13. 36
      pkg/services/store/entity/sqlstash/sqltemplate/into_test.go
  14. 35
      pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate.go
  15. 28
      pkg/services/store/entity/sqlstash/sqltemplate/sqltemplate_test.go

@ -12,3 +12,7 @@ func (a *Args) Arg(x any) string {
*a = append(*a, x) *a = append(*a, x)
return "?" return "?"
} }
func (a *Args) GetArgs() Args {
return *a
}

@ -1,8 +1,6 @@
package sqltemplate package sqltemplate
import ( import "testing"
"testing"
)
func TestArgs_Arg(t *testing.T) { func TestArgs_Arg(t *testing.T) {
t.Parallel() t.Parallel()
@ -22,7 +20,7 @@ func TestArgs_Arg(t *testing.T) {
shouldBeQuestionMark(t, a.Arg(3)) shouldBeQuestionMark(t, a.Arg(3))
shouldBeQuestionMark(t, a.Arg(4)) shouldBeQuestionMark(t, a.Arg(4))
for i, arg := range *a { for i, arg := range a.GetArgs() {
v, ok := arg.(int) v, ok := arg.(int)
if !ok { if !ok {
t.Fatalf("unexpected value: %T(%v)", arg, arg) t.Fatalf("unexpected value: %T(%v)", arg, arg)

@ -29,7 +29,7 @@ type Dialect interface {
// SELECT * // SELECT *
// FROM mytab // FROM mytab
// WHERE id = ? // WHERE id = ?
// {{ .SelectFor Update NoWait }}; -- will be uppercased // {{ .SelectFor "Update NoWait" }}; -- will be uppercased
SelectFor(...string) (string, error) SelectFor(...string) (string, error)
} }
@ -85,7 +85,7 @@ func (rlc rowLockingClauseAll) SelectFor(s ...string) (string, error) {
return "", nil return "", nil
} }
return string(o), nil return "FOR " + string(o), nil
} }
// standardIdent provides standard SQL escaping of identifiers. // standardIdent provides standard SQL escaping of identifiers.

@ -5,14 +5,13 @@ package sqltemplate
// Modes see: // Modes see:
// //
// https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes // https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes
var MySQL mysql var MySQL = mysql{
rowLockingClauseAll: true,
}
var _ Dialect = MySQL var _ Dialect = MySQL
type mysql struct { type mysql struct {
standardIdent standardIdent
} rowLockingClauseAll
func (mysql) SelectFor(s ...string) (string, error) {
return rowLockingClauseAll(true).SelectFor(s...)
} }

@ -1,7 +0,0 @@
package sqltemplate
import "testing"
func TestMySQL_SelectFor(t *testing.T) {
MySQL.SelectFor() //nolint: errcheck,gosec
}

@ -6,7 +6,9 @@ import (
) )
// PostgreSQL is an implementation of Dialect for the PostgreSQL DMBS. // PostgreSQL is an implementation of Dialect for the PostgreSQL DMBS.
var PostgreSQL postgresql var PostgreSQL = postgresql{
rowLockingClauseAll: true,
}
var _ Dialect = PostgreSQL var _ Dialect = PostgreSQL
@ -17,6 +19,7 @@ var (
type postgresql struct { type postgresql struct {
standardIdent standardIdent
rowLockingClauseAll
} }
func (p postgresql) Ident(s string) (string, error) { func (p postgresql) Ident(s string) (string, error) {
@ -28,7 +31,3 @@ func (p postgresql) Ident(s string) (string, error) {
return p.standardIdent.Ident(s) return p.standardIdent.Ident(s)
} }
func (postgresql) SelectFor(s ...string) (string, error) {
return rowLockingClauseAll(true).SelectFor(s...)
}

@ -5,10 +5,6 @@ import (
"testing" "testing"
) )
func TestPostgreSQL_SelectFor(t *testing.T) {
PostgreSQL.SelectFor() //nolint: errcheck,gosec
}
func TestPostgreSQL_Ident(t *testing.T) { func TestPostgreSQL_Ident(t *testing.T) {
t.Parallel() t.Parallel()

@ -1,7 +1,9 @@
package sqltemplate package sqltemplate
// SQLite is an implementation of Dialect for the SQLite DMBS. // SQLite is an implementation of Dialect for the SQLite DMBS.
var SQLite sqlite var SQLite = sqlite{
rowLockingClauseAll: false,
}
var _ Dialect = SQLite var _ Dialect = SQLite
@ -9,8 +11,5 @@ type sqlite struct {
// See: // See:
// https://www.sqlite.org/lang_keywords.html // https://www.sqlite.org/lang_keywords.html
standardIdent standardIdent
} rowLockingClauseAll
func (sqlite) SelectFor(s ...string) (string, error) {
return rowLockingClauseAll(false).SelectFor(s...)
} }

@ -1,7 +0,0 @@
package sqltemplate
import "testing"
func TestSQLite_SelectFor(t *testing.T) {
SQLite.SelectFor() //nolint: errcheck,gosec
}

@ -91,7 +91,7 @@ func TestRowLockingClauseAll_SelectFor(t *testing.T) {
{ {
input: splitSpace(string(SelectForShare)), input: splitSpace(string(SelectForShare)),
output: SelectForShare, output: "FOR " + SelectForShare,
}, },
} }

@ -2,6 +2,7 @@ package sqltemplate
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"text/template" "text/template"
) )
@ -17,105 +18,139 @@ import (
// To learn more about Go's runnable tests, which are a core builtin feature of // To learn more about Go's runnable tests, which are a core builtin feature of
// Go's standard testing library, see: // Go's standard testing library, see:
// https://pkg.go.dev/testing#hdr-Examples // https://pkg.go.dev/testing#hdr-Examples
//
// If you're unfamiliar with Go text templating language, please, consider
// reading that library's documentation first.
// In this example we will use both Args and Dialect to dynamically and securely // In this example we will use both Args and Dialect to dynamically and securely
// build SQL queries, while also keeping track of the arguments that need to be // build SQL queries, while also keeping track of the arguments that need to be
// passed to the database methods to replace the placeholder "?" with the // passed to the database methods to replace the placeholder "?" with the
// correct values. If you're not familiar with Go text templating language, // correct values.
// please, consider reading that library's documentation first.
// We will start by assuming we receive a request to retrieve a user's
// We will start with creating a simple text template to insert a new row into a // information and that we need to provide a certain response.
// users table:
var createUserTmpl = template.Must(template.New("query").Parse(`
INSERT INTO users (id, {{ .Ident "type" }}, name)
VALUES ({{ .Arg .ID }}, {{ .Arg .Type }}, {{ .Arg .Name}});
`))
// The two interesting methods here are Arg and Ident. Note that now we have a type GetUserRequest struct {
// reusable text template, that will dynamically create the SQL code when ID int
// executed, which is interesting because we have a SQL-implementation dependant }
// code handled for us within the template (escaping the reserved word "type"),
// but also because the arguments to the database Exec method will be handled type GetUserResponse struct {
// for us. The struct with the data needed to create a new user could be
// something like the following:
type CreateUserRequest struct {
ID int ID int
Name string
Type string Type string
Name string
} }
// Note that this struct could actually come from a different definition, for // Our template will take care for us of taking the request to build the query,
// example, from a DTO. We can reuse this DTO and create a smaller struct for // and then sort the arguments for execution as well as preparing the values
// the purpose of writing to the database without the need of mapping: // that need to be read for the response. We wil create a struct to pass the
type DBCreateUserRequest struct { // request and an empty response, as well as a *SQLTemplate that will provide
Dialect // provides access to all Dialect methods, like Ident // the methods to achieve our purpose::
*Args // provides access to Arg method, to keep track of db arguments
*CreateUserRequest type GetUserQuery struct {
*SQLTemplate
Request *GetUserRequest
Response *GetUserResponse
} }
// And finally we will define our template, that is free to use all the power of
// the Go templating language, plus the methods we added with *SQLTemplate:
var getUserTmpl = template.Must(template.New("example").Parse(`
SELECT
{{ .Ident "id" | .Into .Response.ID }},
{{ .Ident "type" | .Into .Response.Type }},
{{ .Ident "name" | .Into .Response.Name }}
FROM {{ .Ident "users" }}
WHERE
{{ .Ident "id" }} = {{ .Arg .Request.ID }};
`))
// There are three interesting methods used in the above template:
// 1. Ident: safely escape a SQL identifier. Even though here the only
// identifier that may be problematic is "type" (because it is a reserved
// word in many dialects), it is a good practice to escape all identifiers
// just to make sure we're accounting for all variability in dialects, and
// also for consistency.
// 2. Into: this causes the selected field to be saved to the corresponding
// field of GetUserQuery.
// 3. Arg: this allows us to state that at this point will be a "?" that has to
// be populated with the value of the given field of GetUserQuery.
func Example() { func Example() {
// Finally, we can take a request received from a user like the following: // Let's pretend this example function is the handler of the GetUser method
dto := &CreateUserRequest{ // of our service to see how it all works together.
queryData := &GetUserQuery{
// The dialect (in this case we chose MySQL) should be set in your
// service at startup when you connect to your database
SQLTemplate: New(MySQL),
// This is a synthetic request for our test
Request: &GetUserRequest{
ID: 1, ID: 1,
Name: "root", },
Type: "admin",
}
// Put it into a database request: // Create an empty response to be populated
req := DBCreateUserRequest{ Response: new(GetUserResponse),
Dialect: SQLite, // set at runtime, the template is agnostic
Args: new(Args),
CreateUserRequest: dto,
} }
// Then we finally execute the template to both generate the SQL code and to // The next step is to execute the query template for our queryData, and
// populate req.Args with the arguments: // generate the arguments for the db.QueryRow and row.Scan methods later
var b strings.Builder query, err := Execute(getUserTmpl, queryData)
err := createUserTmpl.Execute(&b, req)
if err != nil { if err != nil {
panic(err) // terminate the runnable example on error panic(err) // terminate the runnable example on error
} }
// And we should finally be able to see the SQL generated, as well as // Assuming that we have a *sql.DB object named "db", we could now make our
// getting the arguments populated for execution in a database. To execute // query with:
// it in the databse, we could run: // row := db.QueryRowContext(ctx, query, queryData.Args...)
// db.ExecContext(ctx, b.String(), req.Args...) // // and check row.Err() here
// To provide the runnable example with some code to test, we will now print
// the values to standard output:
fmt.Println(b.String())
fmt.Printf("%#v", req.Args)
// Output:
// INSERT INTO users (id, "type", name)
// VALUES (?, ?, ?);
//
// &sqltemplate.Args{1, "admin", "root"}
}
// A more complex template example follows, which should be self-explanatory // As we're not actually running a database in this example, let's verify
// given the previous example. It is left as an exercise to the reader how the // that we find our arguments populated as expected instead:
// code should be implemented, based on the ExampleCreateUser function. if len(queryData.Args) != 1 {
panic(fmt.Sprintf("unexpected number of args: %#v", queryData.Args))
}
id, ok := queryData.Args[0].(int)
if !ok || id != queryData.Request.ID {
panic(fmt.Sprintf("unexpected args: %#v", queryData.Args))
}
// List users example. // In your code you would now have "row" populated with the row data,
var _ = template.Must(template.New("query").Parse(` // assuming that the operation succeeded, so you would now scan the row data
SELECT id, {{ .Ident "type" }}, name // abd populate the values of our response:
FROM users // err := row.Scan(queryData.ScanDest...)
WHERE // // and check err here
{{ if eq .By "type" }}
{{ .Ident "type" }} = {{ .Arg .Value }} // Again, as we're not actually running a database in this example, we will
{{ else if eq .By "name" }} // instead run the code to assert that queryData.ScanDest was populated with
name LIKE {{ .Arg .Value }} // the expected data, which should be pointers to each of the fields of
{{ end }}; // Response so that the Scan method can write to them:
`)) if len(queryData.ScanDest) != 3 {
panic(fmt.Sprintf("unexpected number of scan dest: %#v", queryData.ScanDest))
}
idPtr, ok := queryData.ScanDest[0].(*int)
if !ok || idPtr != &queryData.Response.ID {
panic(fmt.Sprintf("unexpected response 'id' pointer: %#v", queryData.ScanDest))
}
typePtr, ok := queryData.ScanDest[1].(*string)
if !ok || typePtr != &queryData.Response.Type {
panic(fmt.Sprintf("unexpected response 'type' pointer: %#v", queryData.ScanDest))
}
namePtr, ok := queryData.ScanDest[2].(*string)
if !ok || namePtr != &queryData.Response.Name {
panic(fmt.Sprintf("unexpected response 'name' pointer: %#v", queryData.ScanDest))
}
type ListUsersRequest struct { // Remember the variable "query"? Well, we didn't check it. We will now make
By string // use of Go's runnable examples and print its contents to standard output
Value string // so Go's tooling verify this example's output each time we run tests.
} // By the way, to make the result more stable, we will remove some
// unnecessary white space from the query.
whiteSpaceRE := regexp.MustCompile(`\s+`)
query = strings.TrimSpace(whiteSpaceRE.ReplaceAllString(query, " "))
fmt.Println(query)
type DBListUsersRequest struct { // Output:
Dialect // SELECT "id", "type", "name" FROM "users" WHERE "id" = ?;
*Args
ListUsersRequest
} }

@ -0,0 +1,22 @@
package sqltemplate
import (
"fmt"
"reflect"
)
type ScanDest []any
func (i *ScanDest) Into(v reflect.Value, colName string) (string, error) {
if !v.IsValid() || !v.CanAddr() || !v.Addr().CanInterface() {
return "", fmt.Errorf("invalid or unaddressable value: %v", colName)
}
*i = append(*i, v.Addr().Interface())
return colName, nil
}
func (i *ScanDest) GetScanDest() ScanDest {
return *i
}

@ -0,0 +1,36 @@
package sqltemplate
import (
"reflect"
"testing"
)
func TestScanDest_Into(t *testing.T) {
t.Parallel()
var d ScanDest
colName, err := d.Into(reflect.Value{}, "some field")
if colName != "" || err == nil || len(d.GetScanDest()) != 0 {
t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v",
colName, err, d)
}
data := struct {
X int
Y byte
}{}
dataVal := reflect.ValueOf(&data).Elem()
colName, err = d.Into(dataVal.FieldByName("X"), "some int")
if err != nil || colName != "some int" || len(d) != 1 || d[0] != &data.X {
t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v",
colName, err, d)
}
colName, err = d.Into(dataVal.FieldByName("Y"), "some byte")
if err != nil || colName != "some byte" || len(d) != 2 || d[1] != &data.Y {
t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v",
colName, err, d)
}
}

@ -0,0 +1,35 @@
package sqltemplate
import (
"strings"
"text/template"
)
type SQLTemplate struct {
Dialect
Args
ScanDest
}
func New(d Dialect) *SQLTemplate {
return &SQLTemplate{
Dialect: d,
}
}
type SQLTemplateIface interface {
Dialect
GetArgs() Args
GetScanDest() ScanDest
}
// Execute is a trivial utility to execute and return the results of any
// text/template as a string and an error.
func Execute(t *template.Template, data any) (string, error) {
var b strings.Builder
if err := t.Execute(&b, data); err != nil {
return "", err
}
return b.String(), nil
}

@ -0,0 +1,28 @@
package sqltemplate
import (
"testing"
"text/template"
)
func TestExecute(t *testing.T) {
t.Parallel()
tmpl := template.Must(template.New("test").Parse(`{{ .ID }}`))
data := struct {
ID int
}{
ID: 1,
}
txt, err := Execute(tmpl, data)
if txt != "1" || err != nil {
t.Fatalf("unexpected error, txt: %q, err: %v", txt, err)
}
txt, err = Execute(tmpl, 1)
if txt != "" || err == nil {
t.Fatalf("unexpected result, txt: %q, err: %v", txt, err)
}
}
Loading…
Cancel
Save