mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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".
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
You can also add `dump.sql` as a DDL data source for proper table column recognition.
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
@@ -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 }}
|
||||
Reference in New Issue
Block a user