chore: Implement joins with golang templates (#6429)

* feat: Implement view for workspace builds to include rbac info

* Removes the need to fetch the workspace to run an rbac check.
* chore: Use workspace build as RBAC object
* chore: Use golang templates instead of sqlc files
This commit is contained in:
Steven Masley
2023-03-10 09:44:38 -06:00
committed by GitHub
parent a666539bfa
commit 8b125d6c5d
36 changed files with 894 additions and 660 deletions
+35
View File
@@ -0,0 +1,35 @@
# Editor/IDE config
To edit template files, it is best to configure your IDE to work with go template files. VSCode gives better highlighting support, as the Goland highlighting tends to recognize the sql as invalid and shows many sql errors in the template file.
## VSCode
Required extension (Default Golang Extension): https://marketplace.visualstudio.com/items?itemName=golang.Go
The default extension [supports syntax highlighting](https://github.com/golang/vscode-go/wiki/features#go-template-syntax-highlighting), but requires a configuration change. You must add this section to your golang extension settings:
```json
"gopls": {
"ui.semanticTokens": true
},
```
The VSCode extension does not support both go template and postgres highlighting. I suggest you use Postgres highlighting, as it is much easier to work with. You can switch between the two with:
1. `ctl + shift + p`
1. "Change language Mode"
1. "Postgres" or "Go Template File"
- Feel free to create a permanent file association with `*.gosql` files.
## Goland
Goland supports [template highlighting](https://www.jetbrains.com/help/go/integration-with-go-templates.html) out of the box. To associate sql files, add a new file type in **Editor** settings. Select "Go template files". Add a new filename of `*.gosql` and select "postgres" as the "Template Data Language".
![Goland language configuration](./imgs/goland-gosql.png)
It also helps to support the sqlc type variables. You can do this by adding ["User Parameters"](https://www.jetbrains.com/help/datagrip/settings-tools-database-user-parameters.html) in database queries.
![Goland language configuration](./imgs/goland-user-params.png)
You can also add `dump.sql` as a DDL data source for proper table column recognition.
+95
View File
@@ -0,0 +1,95 @@
package sqlxqueries
import (
"fmt"
"reflect"
"regexp"
"strings"
"golang.org/x/xerrors"
"github.com/google/uuid"
"github.com/jmoiron/sqlx/reflectx"
"github.com/lib/pq"
"github.com/coder/coder/coderd/util/slice"
)
var nameRegex = regexp.MustCompile(`@([a-zA-Z0-9_]+)`)
// dbmapper grabs struct 'db' tags.
var dbmapper = reflectx.NewMapper("db")
// bindNamed is an implementation that improves on the SQLx implementation. This
// adjusts the query to use "$#" syntax for arguments instead of "@argument". The
// returned args are the values of the struct fields that match the names in the
// correct order and indexing.
//
// 1. SQLx does not reuse arguments, so "@arg, @arg" will result in two arguments
// "$1, $2" instead of "$1, $1".
// 2. SQLx does not handle uuid arrays.
// 3. SQLx only supports ":name" style arguments and breaks "::" type casting.
func bindNamed(query string, arg interface{}) (newQuery string, args []interface{}, err error) {
// We do not need to implement a sql parser to extract and replace the variable names.
// All names follow a simple regex.
names := nameRegex.FindAllString(query, -1)
// Get all unique names
names = slice.Unique(names)
// Replace all names with the correct index
for i, name := range names {
rpl := fmt.Sprintf("$%d", i+1)
if strings.Contains(query, rpl) {
return "", nil,
xerrors.Errorf("query contains both named params %q, and unnamed %q: choose one", name, rpl)
}
query = strings.ReplaceAll(query, name, rpl)
// Remove the "@" prefix to match to the "db" struct tag.
names[i] = strings.TrimPrefix(name, "@")
}
arglist := make([]interface{}, 0, len(names))
// This comes straight from SQLx's implementation to get the values
// of the struct fields.
var v reflect.Value
for v = reflect.ValueOf(arg); v.Kind() == reflect.Ptr; {
v = v.Elem()
}
// If there is only 1 argument, and the argument is not a struct, then
// the only argument is the value passed in. This is a nice shortcut
// for simple queries with 1 param like "id".
if v.Type().Kind() != reflect.Struct && len(names) == 1 {
arglist = append(arglist, pqValue(v))
return query, arglist, nil
}
err = dbmapper.TraversalsByNameFunc(v.Type(), names, func(i int, t []int) error {
if len(t) == 0 {
return xerrors.Errorf("could not find name %s in %#v", names[i], arg)
}
val := reflectx.FieldByIndexesReadOnly(v, t)
arglist = append(arglist, pqValue(val))
return nil
})
if err != nil {
return "", nil, err
}
return query, arglist, nil
}
func pqValue(val reflect.Value) interface{} {
valI := val.Interface()
// Handle some custom types to make arguments easier to use.
switch valI.(type) {
// Feel free to add more types here as needed.
case []uuid.UUID:
return pq.Array(valI)
default:
return valI
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+72
View File
@@ -0,0 +1,72 @@
package sqlxqueries
import (
"context"
"github.com/jmoiron/sqlx"
"golang.org/x/xerrors"
)
// constructQuery will return a SQL query by the given template name.
// It will also return the arguments in order for the query based on the input
// argument.
func constructQuery(queryName string, argument any) (string, []any, error) {
// No argument was given, use an empty struct.
if argument == nil {
argument = struct{}{}
}
query, err := query(queryName, argument)
if err != nil {
return "", nil, xerrors.Errorf("get query: %w", err)
}
query, args, err := bindNamed(query, argument)
if err != nil {
return "", nil, xerrors.Errorf("bind named: %w", err)
}
return query, args, nil
}
// SelectContext runs the named query on the given database.
// If the query returns no rows, an empty slice is returned.
func SelectContext(ctx context.Context, q sqlx.QueryerContext, queryName string, argument any, res any) error {
if q == nil {
return xerrors.New("queryer is nil")
}
query, args, err := constructQuery(queryName, argument)
if err != nil {
return xerrors.Errorf("get query: %w", err)
}
err = sqlx.SelectContext(ctx, q, res, query, args...)
if err != nil {
return xerrors.Errorf("%s: %w", queryName, err)
}
return nil
}
// GetContext runs the named query on the given database.
// If the query returns no rows, sql.ErrNoRows is returned.
func GetContext(ctx context.Context, q sqlx.QueryerContext, queryName string, argument interface{}, res any) error {
if q == nil {
return xerrors.New("queryer is nil")
}
query, args, err := constructQuery(queryName, argument)
if err != nil {
return xerrors.Errorf("get query: %w", err)
}
// GetContext maps the results of the query to the items slice by struct
// db tags.
err = sqlx.GetContext(ctx, q, res, query, args...)
if err != nil {
return xerrors.Errorf("%s: %w", queryName, err)
}
return nil
}
@@ -0,0 +1,55 @@
package sqlxqueries
import (
"bytes"
"embed"
"sync"
"text/template"
"golang.org/x/xerrors"
)
//go:embed *.gosql
var sqlxQueries embed.FS
var (
// Only parse the queries once.
once sync.Once
cached *template.Template
//nolint:errname
cachedError error
)
// LoadQueries parses the embedded queries and returns the template.
// Results are cached.
func LoadQueries() (*template.Template, error) {
once.Do(func() {
tpls, err := template.New("").
Funcs(template.FuncMap{
"int32": func(i int) int32 { return int32(i) },
}).ParseFS(sqlxQueries, "*.gosql")
if err != nil {
cachedError = xerrors.Errorf("developer error parse sqlx queries: %w", err)
return
}
cached = tpls
})
return cached, cachedError
}
// query executes the named template with the given data and returns the result.
// The returned query string is SQL.
func query(name string, data interface{}) (string, error) {
tpls, err := LoadQueries()
if err != nil {
return "", err
}
var out bytes.Buffer
err = tpls.ExecuteTemplate(&out, name, data)
if err != nil {
return "", xerrors.Errorf("execute template %s: %w", name, err)
}
return out.String(), nil
}
@@ -0,0 +1,15 @@
package sqlxqueries_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database/sqlxqueries"
)
func Test_loadQueries(t *testing.T) {
t.Parallel()
_, err := sqlxqueries.LoadQueries()
require.NoError(t, err)
}
+128
View File
@@ -0,0 +1,128 @@
{{ define "workspace_builds_rbac" }}
(
SELECT
workspace_builds.*,
workspaces.organization_id AS organization_id,
workspaces.owner_id AS workspace_owner_id
FROM
workspace_builds
INNER JOIN
workspaces ON workspace_builds.workspace_id = workspaces.id
)
{{ end }};
{{ define "GetWorkspaceBuild" }}
-- name: GetWorkspaceBuild :one
SELECT
*
FROM
{{ template "workspace_builds_rbac" }} workspace_builds
WHERE
CASE
WHEN @build_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
id = @build_id
ELSE true
END
AND CASE
WHEN @job_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
job_id = @job_id
ELSE true
END
AND CASE
WHEN @job_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
job_id = @job_id
ELSE true
END
AND CASE
WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
created_at > @created_after
ELSE true
END
AND CASE
WHEN @workspace_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspace_id = @workspace_id
ELSE true
END
AND CASE
WHEN @build_number :: integer != 0 THEN
build_number = @build_number
ELSE true
END
{{ if .Latest }}
ORDER BY
build_number desc
{{ end }}
{{ if gt .LimitOpt 0 }} LIMIT @limit_opt {{ end }}
;
{{ end }}
{{ define "GetWorkspaceBuildsByWorkspaceID" }}
-- name: GetWorkspaceBuildsByWorkspaceID :many
SELECT
*
FROM
{{ template "workspace_builds_rbac" }} workspace_builds
WHERE
workspace_builds.workspace_id = @workspace_id
AND workspace_builds.created_at > @since
AND CASE
-- This allows using the last element on a page as effectively a cursor.
-- This is an important option for scripts that need to paginate without
-- duplicating or missing data.
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the build_number field, so select all
-- rows after the cursor.
build_number > (
SELECT
build_number
FROM
workspace_builds
WHERE
id = @after_id
)
)
ELSE true
END
ORDER BY
build_number desc OFFSET @offset_opt
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF(@limit_opt :: int, 0);
{{ end }}
{{ define "GetLatestWorkspaceBuildsByWorkspaceIDs" }}
-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many
SELECT wb.*
FROM (
SELECT
workspace_id, MAX(build_number) as max_build_number
FROM
workspace_builds
WHERE
workspace_id = ANY(@ids :: uuid [ ])
GROUP BY
workspace_id
) m
JOIN
{{ template "workspace_builds_rbac" }} wb
ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number;
{{ end }}
{{ define "GetLatestWorkspaceBuilds" }}
-- name: GetLatestWorkspaceBuilds :many
SELECT wb.*
FROM (
SELECT
workspace_id, MAX(build_number) as max_build_number
FROM
workspace_builds
GROUP BY
workspace_id
) m
JOIN
{{ template "workspace_builds_rbac" }} wb
ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number;
{{ end }}