Unified Storage: Fix Create, Update and Delete wrt Resource Versions (#88183)
* add sqltemplate utilities, improve tests and documentation * bunch of things * remove unnecessary message * add queries * add queries * add queries * add folders support * fix diff * fix linters * fix diff * fix linters * fix linters * fix typo * fix linters * fix linters * fix linters * several fixes * several fixes * temporarily disable k8s integration tests for Entity Server * postpone some tests * postpone documentation changes * Fix bug in create * improve error reporting * fix PostgeSQL parameters * fix MySQL sqlmode * fix MySQL-5.7 * reduce but document the number of database connection options * remove unused code and improve docs
This commit is contained in:
@@ -23,7 +23,7 @@ func NewBroadcaster[T any](ctx context.Context, connect ConnectFunc[T]) (Broadca
|
||||
}
|
||||
|
||||
type broadcaster[T any] struct {
|
||||
running bool
|
||||
running bool // FIXME: race condition between `Subscribe`/`Unsubscribe` and `start`
|
||||
ctx context.Context
|
||||
subs map[chan T]struct{}
|
||||
cache Cache[T]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
|
||||
)
|
||||
|
||||
func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequest) (*entity.CreateEntityResponse, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "storage_server.Create")
|
||||
defer span.End()
|
||||
|
||||
key, err := entity.ParseKey(r.Entity.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create entity: parse entity key: %w", err)
|
||||
}
|
||||
|
||||
// validate and process the request to get the information we need to run
|
||||
// the query
|
||||
newEntity, err := entityForCreate(ctx, r, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create entity: entity from create entity request: %w", err)
|
||||
}
|
||||
|
||||
err = s.sqlDB.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
|
||||
if len(newEntity.Entity.Labels) > 0 {
|
||||
// Pre-locking: register this entity's labels
|
||||
insLabels := sqlEntityLabelsInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
GUID: newEntity.Guid,
|
||||
Labels: newEntity.Entity.Labels,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityLabelsInsert, insLabels); err != nil {
|
||||
return fmt.Errorf("insert into entity_labels: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// up to this point, we have done all the work possible before having to
|
||||
// lock kind_version
|
||||
|
||||
// 1. Atomically increpement resource version for this kind
|
||||
newVersion, err := kindVersionAtomicInc(ctx, tx, s.sqlDialect, key.Group, key.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newEntity.ResourceVersion = newVersion
|
||||
|
||||
// 2. Insert into entity
|
||||
insEntity := sqlEntityInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: newEntity,
|
||||
TableEntity: true,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityInsert, insEntity); err != nil {
|
||||
return fmt.Errorf("insert into entity: %w", err)
|
||||
}
|
||||
|
||||
// 3. Insert into entity history
|
||||
insEntityHistory := sqlEntityInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: newEntity,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityInsert, insEntityHistory); err != nil {
|
||||
return fmt.Errorf("insert into entity_history: %w", err)
|
||||
}
|
||||
|
||||
// 4. Rebuild the whole folder tree structure if we're creating a folder
|
||||
if newEntity.Group == folder.GROUP && newEntity.Resource == folder.RESOURCE {
|
||||
if err = s.updateFolderTree(ctx, tx, key.Namespace); err != nil {
|
||||
return fmt.Errorf("rebuild folder tree structure: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// TODO: should we define the "Error" field here and how? (i.e. how
|
||||
// to determine what information can be disclosed to the user?)
|
||||
return nil, fmt.Errorf("create entity: %w", err)
|
||||
}
|
||||
|
||||
return &entity.CreateEntityResponse{
|
||||
Entity: newEntity.Entity,
|
||||
Status: entity.CreateEntityResponse_CREATED,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// entityForCreate validates the given request and returns a *returnsEntity
|
||||
// populated accordingly.
|
||||
func entityForCreate(ctx context.Context, r *entity.CreateEntityRequest, key *entity.Key) (*returnsEntity, error) {
|
||||
newEntity := &returnsEntity{
|
||||
Entity: cloneEntity(r.Entity),
|
||||
}
|
||||
if err := newEntity.marshal(); err != nil {
|
||||
return nil, fmt.Errorf("serialize entity data for db: %w", err)
|
||||
}
|
||||
|
||||
createdAt := time.Now().UnixMilli()
|
||||
createdBy, err := getCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newEntity.Guid = uuid.New().String()
|
||||
|
||||
newEntity.Group = key.Group
|
||||
newEntity.Resource = key.Resource
|
||||
newEntity.Namespace = key.Namespace
|
||||
newEntity.Name = key.Name
|
||||
|
||||
newEntity.Size = int64(len(r.Entity.Body))
|
||||
newEntity.ETag = createETag(r.Entity.Body, r.Entity.Meta, r.Entity.Status)
|
||||
|
||||
newEntity.CreatedAt = createdAt
|
||||
newEntity.CreatedBy = createdBy
|
||||
newEntity.UpdatedAt = createdAt
|
||||
newEntity.UpdatedBy = createdBy
|
||||
|
||||
newEntity.Action = entity.Entity_CREATED
|
||||
|
||||
return newEntity, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{/*
|
||||
This is the list of all the fields in *entity.Entity, in a way that is
|
||||
suitable to be imported by other templates that need to select these fields
|
||||
from either the "entity" or the "entity_history" tables.
|
||||
|
||||
Example usage:
|
||||
|
||||
SELECT {{ template "common_entity_select_into" . }}
|
||||
FROM {{ .Ident "entity" }} AS e
|
||||
|
||||
*/}}
|
||||
{{ define "common_entity_select_into" }}
|
||||
|
||||
e.{{ .Ident "guid" | .Into .Entity.Guid }},
|
||||
e.{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }},
|
||||
|
||||
e.{{ .Ident "key" | .Into .Entity.Key }},
|
||||
|
||||
e.{{ .Ident "group" | .Into .Entity.Group }},
|
||||
e.{{ .Ident "group_version" | .Into .Entity.GroupVersion }},
|
||||
e.{{ .Ident "resource" | .Into .Entity.Resource }},
|
||||
e.{{ .Ident "namespace" | .Into .Entity.Namespace }},
|
||||
e.{{ .Ident "name" | .Into .Entity.Name }},
|
||||
|
||||
e.{{ .Ident "folder" | .Into .Entity.Folder }},
|
||||
|
||||
e.{{ .Ident "meta" | .Into .Entity.Meta }},
|
||||
e.{{ .Ident "body" | .Into .Entity.Body }},
|
||||
e.{{ .Ident "status" | .Into .Entity.Status }},
|
||||
|
||||
e.{{ .Ident "size" | .Into .Entity.Size }},
|
||||
e.{{ .Ident "etag" | .Into .Entity.ETag }},
|
||||
|
||||
e.{{ .Ident "created_at" | .Into .Entity.CreatedAt }},
|
||||
e.{{ .Ident "created_by" | .Into .Entity.CreatedBy }},
|
||||
e.{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }},
|
||||
e.{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }},
|
||||
|
||||
e.{{ .Ident "origin" | .Into .Entity.Origin.Source }},
|
||||
e.{{ .Ident "origin_key" | .Into .Entity.Origin.Key }},
|
||||
e.{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }},
|
||||
|
||||
e.{{ .Ident "title" | .Into .Entity.Title }},
|
||||
e.{{ .Ident "slug" | .Into .Entity.Slug }},
|
||||
e.{{ .Ident "description" | .Into .Entity.Description }},
|
||||
|
||||
e.{{ .Ident "message" | .Into .Entity.Message }},
|
||||
e.{{ .Ident "labels" | .Into .Entity.Labels }},
|
||||
e.{{ .Ident "fields" | .Into .Entity.Fields }},
|
||||
e.{{ .Ident "errors" | .Into .Entity.Errors }},
|
||||
|
||||
e.{{ .Ident "action" | .Into .Entity.Action }}
|
||||
{{ end }}
|
||||
|
||||
{{/* Build an ORDER BY clause from a []SortBy contained in a .Sort field */}}
|
||||
{{ define "common_order_by" }}
|
||||
{{ $comma := listSep ", " }}
|
||||
{{ range .Sort }}
|
||||
{{- call $comma -}} {{ $.Ident .Field }} {{ .Direction.String }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -12,24 +12,18 @@ INSERT INTO {{ .Ident "entity_folder" }}
|
||||
)
|
||||
|
||||
VALUES
|
||||
{{ $this := . }}
|
||||
{{ $addComma := false }}
|
||||
{{ $comma := listSep ", " }}
|
||||
{{ range .Items }}
|
||||
{{ if $addComma }}
|
||||
,
|
||||
{{ end }}
|
||||
{{ $addComma = true }}
|
||||
|
||||
(
|
||||
{{ $this.Arg .GUID }},
|
||||
{{ $this.Arg .Namespace }},
|
||||
{{ $this.Arg .UID }},
|
||||
{{ $this.Arg .SlugPath }},
|
||||
{{ $this.Arg .JS }},
|
||||
{{ $this.Arg .Depth }},
|
||||
{{ $this.Arg .Left }},
|
||||
{{ $this.Arg .Right }},
|
||||
{{ $this.Arg .Detached }}
|
||||
{{- call $comma -}} (
|
||||
{{ $.Arg .GUID }},
|
||||
{{ $.Arg .Namespace }},
|
||||
{{ $.Arg .UID }},
|
||||
{{ $.Arg .SlugPath }},
|
||||
{{ $.Arg .JS }},
|
||||
{{ $.Arg .Depth }},
|
||||
{{ $.Arg .Left }},
|
||||
{{ $.Arg .Right }},
|
||||
{{ $.Arg .Detached }}
|
||||
)
|
||||
{{ end }}
|
||||
;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
SELECT {{ template "common_entity_select_into" . }}
|
||||
|
||||
FROM {{ .Ident "entity_history" }} AS e
|
||||
|
||||
WHERE 1 = 1
|
||||
|
||||
{{ if gt .Before 0 }}
|
||||
AND {{ .Ident "resource_version" }} < {{ .Arg .Before }}
|
||||
{{ end }}
|
||||
|
||||
{{/* There are two mutually exclusive search modes: by GUID and by Key */}}
|
||||
|
||||
{{ if ne .Query.GUID "" }}
|
||||
AND {{ .Ident "guid" }} = {{ .Arg .Query.GUID }}
|
||||
|
||||
{{ else }}
|
||||
AND {{ .Ident "group" }} = {{ .Arg .Query.Key.Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Query.Key.Resource }}
|
||||
AND {{ .Ident "name" }} = {{ .Arg .Query.Key.Name }}
|
||||
|
||||
{{ if ne .Query.Key.Namespace "" }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Query.Key.Namespace }}
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
|
||||
ORDER BY {{ template "common_order_by" . }}
|
||||
LIMIT {{ .Limit }}
|
||||
OFFSET {{ .Offset }}
|
||||
;
|
||||
@@ -2,17 +2,6 @@ DELETE FROM {{ .Ident "entity_labels" }}
|
||||
WHERE 1 = 1
|
||||
AND {{ .Ident "guid" }} = {{ .Arg .GUID }}
|
||||
{{ if gt (len .KeepLabels) 0 }}
|
||||
AND {{ .Ident "label" }} NOT IN (
|
||||
{{ $this := . }}
|
||||
{{ $addComma := false }}
|
||||
{{ range .KeepLabels }}
|
||||
{{ if $addComma }}
|
||||
,
|
||||
{{ end }}
|
||||
{{ $addComma = true }}
|
||||
|
||||
{{ $this.Arg . }}
|
||||
{{ end }}
|
||||
)
|
||||
AND {{ .Ident "label" }} NOT IN ( {{ .ArgList .KeepLabels }} )
|
||||
{{ end }}
|
||||
;
|
||||
|
||||
@@ -6,24 +6,12 @@ INSERT INTO {{ .Ident "entity_labels" }}
|
||||
)
|
||||
|
||||
VALUES
|
||||
{{/*
|
||||
When we enter the "range" loop the "." will be changed, so we need to
|
||||
store the current ".GUID" in a variable to be able to use its value
|
||||
*/}}
|
||||
{{ $guid := .GUID }}
|
||||
|
||||
{{ $this := . }}
|
||||
{{ $addComma := false }}
|
||||
{{ $comma := listSep ", " }}
|
||||
{{ range $name, $value := .Labels }}
|
||||
{{ if $addComma }}
|
||||
,
|
||||
{{ end }}
|
||||
{{ $addComma = true }}
|
||||
|
||||
(
|
||||
{{ $this.Arg $guid }},
|
||||
{{ $this.Arg $name }},
|
||||
{{ $this.Arg $value }}
|
||||
{{- call $comma -}} (
|
||||
{{ $.Arg $.GUID }},
|
||||
{{ $.Arg $name }},
|
||||
{{ $.Arg $value }}
|
||||
)
|
||||
{{ end }}
|
||||
;
|
||||
|
||||
@@ -1,49 +1,10 @@
|
||||
SELECT
|
||||
{{ .Ident "guid" | .Into .Entity.Guid }},
|
||||
{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }},
|
||||
|
||||
{{ .Ident "key" | .Into .Entity.Key }},
|
||||
|
||||
{{ .Ident "group" | .Into .Entity.Group }},
|
||||
{{ .Ident "group_version" | .Into .Entity.GroupVersion }},
|
||||
{{ .Ident "resource" | .Into .Entity.Resource }},
|
||||
{{ .Ident "namespace" | .Into .Entity.Namespace }},
|
||||
{{ .Ident "name" | .Into .Entity.Name }},
|
||||
|
||||
{{ .Ident "folder" | .Into .Entity.Folder }},
|
||||
|
||||
{{ .Ident "meta" | .Into .Entity.Meta }},
|
||||
{{ .Ident "body" | .Into .Entity.Body }},
|
||||
{{ .Ident "status" | .Into .Entity.Status }},
|
||||
|
||||
{{ .Ident "size" | .Into .Entity.Size }},
|
||||
{{ .Ident "etag" | .Into .Entity.ETag }},
|
||||
|
||||
{{ .Ident "created_at" | .Into .Entity.CreatedAt }},
|
||||
{{ .Ident "created_by" | .Into .Entity.CreatedBy }},
|
||||
{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }},
|
||||
{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }},
|
||||
|
||||
{{ .Ident "origin" | .Into .Entity.Origin.Source }},
|
||||
{{ .Ident "origin_key" | .Into .Entity.Origin.Key }},
|
||||
{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }},
|
||||
|
||||
{{ .Ident "title" | .Into .Entity.Title }},
|
||||
{{ .Ident "slug" | .Into .Entity.Slug }},
|
||||
{{ .Ident "description" | .Into .Entity.Description }},
|
||||
|
||||
{{ .Ident "message" | .Into .Entity.Message }},
|
||||
{{ .Ident "labels" | .Into .Entity.Labels }},
|
||||
{{ .Ident "fields" | .Into .Entity.Fields }},
|
||||
{{ .Ident "errors" | .Into .Entity.Errors }},
|
||||
|
||||
{{ .Ident "action" | .Into .Entity.Action }}
|
||||
SELECT {{ template "common_entity_select_into" . }}
|
||||
|
||||
FROM
|
||||
{{ if gt .ResourceVersion 0 }}
|
||||
{{ .Ident "entity_history" }}
|
||||
{{ .Ident "entity_history" }} AS e
|
||||
{{ else }}
|
||||
{{ .Ident "entity" }}
|
||||
{{ .Ident "entity" }} AS e
|
||||
{{ end }}
|
||||
|
||||
WHERE 1 = 1
|
||||
@@ -73,6 +34,6 @@ SELECT
|
||||
{{ end }}
|
||||
|
||||
{{ if .SelectForUpdate }}
|
||||
{{ .SelectFor "UPDATE" }}
|
||||
{{ .SelectFor "UPDATE NOWAIT" }}
|
||||
{{ end }}
|
||||
;
|
||||
|
||||
@@ -1,43 +1,4 @@
|
||||
SELECT
|
||||
e.{{ .Ident "guid" | .Into .Entity.Guid }},
|
||||
e.{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }},
|
||||
|
||||
e.{{ .Ident "key" | .Into .Entity.Key }},
|
||||
|
||||
e.{{ .Ident "group" | .Into .Entity.Group }},
|
||||
e.{{ .Ident "group_version" | .Into .Entity.GroupVersion }},
|
||||
e.{{ .Ident "resource" | .Into .Entity.Resource }},
|
||||
e.{{ .Ident "namespace" | .Into .Entity.Namespace }},
|
||||
e.{{ .Ident "name" | .Into .Entity.Name }},
|
||||
|
||||
e.{{ .Ident "folder" | .Into .Entity.Folder }},
|
||||
|
||||
e.{{ .Ident "meta" | .Into .Entity.Meta }},
|
||||
e.{{ .Ident "body" | .Into .Entity.Body }},
|
||||
e.{{ .Ident "status" | .Into .Entity.Status }},
|
||||
|
||||
e.{{ .Ident "size" | .Into .Entity.Size }},
|
||||
e.{{ .Ident "etag" | .Into .Entity.ETag }},
|
||||
|
||||
e.{{ .Ident "created_at" | .Into .Entity.CreatedAt }},
|
||||
e.{{ .Ident "created_by" | .Into .Entity.CreatedBy }},
|
||||
e.{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }},
|
||||
e.{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }},
|
||||
|
||||
e.{{ .Ident "origin" | .Into .Entity.Origin.Source }},
|
||||
e.{{ .Ident "origin_key" | .Into .Entity.Origin.Key }},
|
||||
e.{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }},
|
||||
|
||||
e.{{ .Ident "title" | .Into .Entity.Title }},
|
||||
e.{{ .Ident "slug" | .Into .Entity.Slug }},
|
||||
e.{{ .Ident "description" | .Into .Entity.Description }},
|
||||
|
||||
e.{{ .Ident "message" | .Into .Entity.Message }},
|
||||
e.{{ .Ident "labels" | .Into .Entity.Labels }},
|
||||
e.{{ .Ident "fields" | .Into .Entity.Fields }},
|
||||
e.{{ .Ident "errors" | .Into .Entity.Errors }},
|
||||
|
||||
e.{{ .Ident "action" | .Into .Entity.Action }}
|
||||
SELECT {{ template "common_entity_select_into" . }}
|
||||
|
||||
FROM
|
||||
{{ .Ident "entity_ref" }} AS r
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
SELECT
|
||||
{{ .Ident "resource_version" | .Into .ResourceVersion }},
|
||||
{{ .Ident "created_at" | .Into .ResourceVersion }},
|
||||
{{ .Ident "updated_at" | .Into .ResourceVersion }}
|
||||
|
||||
FROM {{ .Ident "kind_version" }}
|
||||
WHERE 1 = 1
|
||||
AND {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
;
|
||||
@@ -1,5 +1,8 @@
|
||||
UPDATE {{ .Ident "kind_version" }}
|
||||
SET {{ .Ident "resource_version" }} = {{ .Arg .ResourceVersion }} + 1
|
||||
SET
|
||||
{{ .Ident "resource_version" }} = {{ .Arg .ResourceVersion }} + 1,
|
||||
{{ .Ident "updated_at" }} = {{ .Arg .UpdatedAt }}
|
||||
|
||||
WHERE 1 = 1
|
||||
AND {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
|
||||
@@ -2,12 +2,16 @@ INSERT INTO {{ .Ident "kind_version" }}
|
||||
(
|
||||
{{ .Ident "group" }},
|
||||
{{ .Ident "resource" }},
|
||||
{{ .Ident "resource_version" }}
|
||||
{{ .Ident "resource_version" }},
|
||||
{{ .Ident "created_at" }},
|
||||
{{ .Ident "updated_at" }}
|
||||
)
|
||||
|
||||
VALUES (
|
||||
{{ .Arg .Group }},
|
||||
{{ .Arg .Resource }},
|
||||
1
|
||||
1,
|
||||
{{ .Arg .CreatedAt }},
|
||||
{{ .Arg .UpdatedAt }}
|
||||
)
|
||||
;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
SELECT {{ .Ident "resource_version" | .Into .ResourceVersion }}
|
||||
FROM {{ .Ident "kind_version" }}
|
||||
WHERE 1 = 1
|
||||
AND {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
{{ .SelectFor "UPDATE" }}
|
||||
;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
|
||||
)
|
||||
|
||||
func (s *sqlEntityServer) Delete(ctx context.Context, r *entity.DeleteEntityRequest) (*entity.DeleteEntityResponse, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "storage_server.Delete")
|
||||
defer span.End()
|
||||
|
||||
key, err := entity.ParseKey(r.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete entity: parse entity key: %w", err)
|
||||
}
|
||||
|
||||
updatedBy, err := getCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete entity: %w", err)
|
||||
}
|
||||
|
||||
ret := new(entity.DeleteEntityResponse)
|
||||
|
||||
err = s.sqlDB.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
|
||||
// Pre-locking: get the latest version of the entity
|
||||
previous, err := readEntity(ctx, tx, s.sqlDialect, key, r.PreviousVersion, true, false)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
ret.Status = entity.DeleteEntityResponse_NOTFOUND
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pre-locking: remove this entity's labels
|
||||
delLabelsReq := sqlEntityLabelsDeleteRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
GUID: previous.Guid,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityLabelsDelete, delLabelsReq); err != nil {
|
||||
return fmt.Errorf("delete all labels of entity with guid %q: %w",
|
||||
previous.Guid, err)
|
||||
}
|
||||
|
||||
// TODO: Pre-locking: remove this entity's refs from `entity_ref`
|
||||
|
||||
// Pre-locking: delete from "entity"
|
||||
delEntityReq := sqlEntityDeleteRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Key: key,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityDelete, delEntityReq); err != nil {
|
||||
return fmt.Errorf("delete entity with key %#v: %w", key, err)
|
||||
}
|
||||
|
||||
// Pre-locking: rebuild the whole folder tree structure if we're
|
||||
// deleting a folder
|
||||
if previous.Group == folder.GROUP && previous.Resource == folder.RESOURCE {
|
||||
if err = s.updateFolderTree(ctx, tx, key.Namespace); err != nil {
|
||||
return fmt.Errorf("rebuild folder tree structure: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// up to this point, we have done all the work possible before having to
|
||||
// lock kind_version
|
||||
|
||||
// 1. Atomically increpement resource version for this kind
|
||||
newVersion, err := kindVersionAtomicInc(ctx, tx, s.sqlDialect, key.Group, key.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// k8s expects us to return the entity as it was before the deletion,
|
||||
// but with the updated RV
|
||||
previous.ResourceVersion = newVersion
|
||||
|
||||
// build the new row to be inserted
|
||||
deletedVersion := *previous // copy marshaled data since it won't change
|
||||
deletedVersion.Entity = cloneEntity(previous.Entity) // clone entity
|
||||
deletedVersion.Action = entity.Entity_DELETED
|
||||
deletedVersion.UpdatedAt = time.Now().UnixMilli()
|
||||
deletedVersion.UpdatedBy = updatedBy
|
||||
|
||||
// 2. Insert into entity history
|
||||
insEntity := sqlEntityInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: &deletedVersion,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityInsert, insEntity); err != nil {
|
||||
return fmt.Errorf("insert into entity_history: %w", err)
|
||||
}
|
||||
|
||||
// success
|
||||
ret.Status = entity.DeleteEntityResponse_DELETED
|
||||
ret.Entity = previous.Entity
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// TODO: should we populate the Error field and how? (i.e. how to
|
||||
// determine what information can be disclosed to the user?)
|
||||
return nil, fmt.Errorf("delete entity: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
@@ -3,13 +3,16 @@ package sqlstash
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/session"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
|
||||
)
|
||||
|
||||
type folderInfo struct {
|
||||
Guid string `json:"guid"`
|
||||
GUID string `json:"guid"`
|
||||
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"` // original display name
|
||||
@@ -23,7 +26,7 @@ type folderInfo struct {
|
||||
right int32
|
||||
|
||||
// Build the tree
|
||||
parentUID string
|
||||
ParentUID string
|
||||
|
||||
// Calculated after query
|
||||
parent *folderInfo
|
||||
@@ -31,56 +34,101 @@ type folderInfo struct {
|
||||
stack []*folderInfo
|
||||
}
|
||||
|
||||
// This will replace all entries in `entity_folder`
|
||||
// This is pretty heavy weight, but it does give us a sorted folder list
|
||||
// NOTE: this could be done async with a mutex/lock? reconciler pattern
|
||||
func (s *sqlEntityServer) updateFolderTree(ctx context.Context, tx *session.SessionTx, namespace string) error {
|
||||
_, err := tx.Exec(ctx, "DELETE FROM entity_folder WHERE namespace=?", namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
func (fi *folderInfo) buildInsertItems(items *[]*sqlEntityFolderInsertRequestItem, namespace string, isLost bool) error {
|
||||
var js strings.Builder
|
||||
if err := json.NewEncoder(&js).Encode(fi.stack); err != nil {
|
||||
return fmt.Errorf("marshal stack of folder %q to JSON: %w", fi.SlugPath, err)
|
||||
}
|
||||
|
||||
query := "SELECT guid,name,folder,name,slug" +
|
||||
" FROM entity" +
|
||||
" WHERE " + s.dialect.Quote("group") + "=? AND resource=? AND namespace=?" +
|
||||
" ORDER BY slug asc"
|
||||
args := []interface{}{folder.GROUP, folder.RESOURCE, namespace}
|
||||
*items = append(*items, &sqlEntityFolderInsertRequestItem{
|
||||
GUID: fi.GUID,
|
||||
Namespace: namespace,
|
||||
UID: fi.UID,
|
||||
SlugPath: fi.SlugPath,
|
||||
JS: js.String(),
|
||||
Depth: fi.depth,
|
||||
Left: fi.left,
|
||||
Right: fi.right,
|
||||
Detached: isLost,
|
||||
})
|
||||
|
||||
all := []*folderInfo{}
|
||||
rows, err := tx.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
folder := folderInfo{
|
||||
children: []*folderInfo{},
|
||||
}
|
||||
err = rows.Scan(&folder.Guid, &folder.UID, &folder.parentUID, &folder.Name, &folder.Slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, &folder)
|
||||
}
|
||||
|
||||
root, lost, err := buildFolderTree(all)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = insertFolderInfo(ctx, tx, namespace, root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, folder := range lost {
|
||||
err = insertFolderInfo(ctx, tx, namespace, folder, true)
|
||||
if err != nil {
|
||||
return err
|
||||
for _, sub := range fi.children {
|
||||
if err := sub.buildInsertItems(items, namespace, isLost); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// This rebuilds the whole folders structure for a given namespace. This has to
|
||||
// be done each time an entity is created or deleted.
|
||||
// FIXME: This is very inefficient and time consuming. This could be implemented
|
||||
// with a different approach instead of MPTT, or at least mitigated by an async
|
||||
// job?
|
||||
// FIXME: This algorithm apparently allows lost trees which are called
|
||||
// "detached"? We should probably migrate to something safer.
|
||||
func (s *sqlEntityServer) updateFolderTree(ctx context.Context, x db.ContextExecer, namespace string) error {
|
||||
_, err := x.ExecContext(ctx, "DELETE FROM entity_folder WHERE namespace=?", namespace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear entity_folder for namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
listReq := sqlEntityListFolderElementsRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Group: folder.GROUP,
|
||||
Resource: folder.RESOURCE,
|
||||
Namespace: namespace,
|
||||
FolderInfo: new(folderInfo),
|
||||
}
|
||||
query, err := sqltemplate.Execute(sqlEntityListFolderElements, listReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute SQL template to list folder items in namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
rows, err := x.QueryContext(ctx, query, listReq.GetArgs()...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list folder items in namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
var itemList []*folderInfo
|
||||
for i := 1; rows.Next(); i++ {
|
||||
if err := rows.Scan(listReq.GetScanDest()...); err != nil {
|
||||
return fmt.Errorf("scan row #%d listing folder items in namespace %q: %w", i, namespace, err)
|
||||
}
|
||||
fi := *listReq.FolderInfo
|
||||
itemList = append(itemList, &fi)
|
||||
}
|
||||
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("close rows after listing folder items in namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
root, lost, err := buildFolderTree(itemList)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build folder tree for namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
var insertItems []*sqlEntityFolderInsertRequestItem
|
||||
if err = root.buildInsertItems(&insertItems, namespace, false); err != nil {
|
||||
return fmt.Errorf("build insert items for root tree in namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
for i, lostItem := range lost {
|
||||
if err = lostItem.buildInsertItems(&insertItems, namespace, false); err != nil {
|
||||
return fmt.Errorf("build insert items for lost folder #%d tree in namespace %q: %w", i, namespace, err)
|
||||
}
|
||||
}
|
||||
|
||||
insReq := sqlEntityFolderInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Items: insertItems,
|
||||
}
|
||||
if _, err = exec(ctx, x, sqlEntityFolderInsert, insReq); err != nil {
|
||||
return fmt.Errorf("insert rebuilt tree for namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFolderTree(all []*folderInfo) (*folderInfo, []*folderInfo, error) {
|
||||
@@ -100,7 +148,7 @@ func buildFolderTree(all []*folderInfo) (*folderInfo, []*folderInfo, error) {
|
||||
|
||||
// already sorted by slug
|
||||
for _, folder := range all {
|
||||
parent, ok := lookup[folder.parentUID]
|
||||
parent, ok := lookup[folder.ParentUID]
|
||||
if ok {
|
||||
folder.parent = parent
|
||||
parent.children = append(parent.children, folder)
|
||||
@@ -136,32 +184,3 @@ func setMPTTOrder(folder *folderInfo, stack []*folderInfo, idx int32) (int32, er
|
||||
folder.right = idx + 1
|
||||
return folder.right, nil
|
||||
}
|
||||
|
||||
func insertFolderInfo(ctx context.Context, tx *session.SessionTx, namespace string, folder *folderInfo, isDetached bool) error {
|
||||
js, _ := json.Marshal(folder.stack)
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO entity_folder `+
|
||||
"(guid, namespace, name, slug_path, tree, depth, lft, rgt, detached) "+
|
||||
`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
folder.Guid,
|
||||
namespace,
|
||||
folder.UID,
|
||||
folder.SlugPath,
|
||||
string(js),
|
||||
folder.depth,
|
||||
folder.left,
|
||||
folder.right,
|
||||
isDetached,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, sub := range folder.children {
|
||||
err := insertFolderInfo(ctx, tx, namespace, sub, isDetached)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,16 +5,17 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/experimental"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFolderSupport(t *testing.T) {
|
||||
root, lost, err := buildFolderTree([]*folderInfo{
|
||||
{Guid: "GA", UID: "A", parentUID: "", Name: "A", Slug: "a"},
|
||||
{Guid: "GAA", UID: "AA", parentUID: "A", Name: "AA", Slug: "aa"},
|
||||
{Guid: "GB", UID: "B", parentUID: "", Name: "B", Slug: "b"},
|
||||
{GUID: "GA", UID: "A", ParentUID: "", Name: "A", Slug: "a"},
|
||||
{GUID: "GAA", UID: "AA", ParentUID: "A", Name: "AA", Slug: "aa"},
|
||||
{GUID: "GB", UID: "B", ParentUID: "", Name: "B", Slug: "b"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, root)
|
||||
|
||||
@@ -1,47 +1,111 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
|
||||
)
|
||||
|
||||
// Templates.
|
||||
// Templates setup.
|
||||
var (
|
||||
//go:embed data
|
||||
templatesFs embed.FS
|
||||
//go:embed data/*.sql
|
||||
sqlTemplatesFS embed.FS
|
||||
|
||||
// all templates
|
||||
templates = template.Must(template.ParseFS(templatesFs, `data/*.sql`))
|
||||
|
||||
sqlEntityDelete = getTemplate("entity_delete.sql")
|
||||
sqlEntityInsert = getTemplate("entity_insert.sql")
|
||||
sqlEntityListFolderElements = getTemplate("entity_list_folder_elements.sql")
|
||||
sqlEntityUpdate = getTemplate("entity_update.sql")
|
||||
sqlEntityRead = getTemplate("entity_read.sql")
|
||||
|
||||
sqlEntityFolderInsert = getTemplate("entity_folder_insert.sql")
|
||||
|
||||
sqlEntityRefFind = getTemplate("entity_ref_find.sql")
|
||||
|
||||
sqlEntityLabelsDelete = getTemplate("entity_labels_delete.sql")
|
||||
sqlEntityLabelsInsert = getTemplate("entity_labels_insert.sql")
|
||||
|
||||
sqlKindVersionInc = getTemplate("kind_version_inc.sql")
|
||||
sqlKindVersionInsert = getTemplate("kind_version_insert.sql")
|
||||
sqlKindVersionLock = getTemplate("kind_version_lock.sql")
|
||||
helpers = template.FuncMap{
|
||||
"listSep": helperListSep,
|
||||
"join": helperJoin,
|
||||
}
|
||||
sqlTemplates = template.Must(template.New("sql").Funcs(helpers).ParseFS(sqlTemplatesFS, `data/*.sql`))
|
||||
)
|
||||
|
||||
func getTemplate(filename string) *template.Template {
|
||||
if t := templates.Lookup(filename); t != nil {
|
||||
func mustTemplate(filename string) *template.Template {
|
||||
if t := sqlTemplates.Lookup(filename); t != nil {
|
||||
return t
|
||||
}
|
||||
panic(fmt.Sprintf("template file not found: %s", filename))
|
||||
}
|
||||
|
||||
// Templates.
|
||||
var (
|
||||
sqlEntityDelete = mustTemplate("entity_delete.sql")
|
||||
sqlEntityHistory = mustTemplate("entity_history.sql")
|
||||
//sqlEntityHistoryList = mustTemplate("entity_history_list.sql") // TODO: in upcoming PRs
|
||||
sqlEntityInsert = mustTemplate("entity_insert.sql")
|
||||
sqlEntityListFolderElements = mustTemplate("entity_list_folder_elements.sql")
|
||||
sqlEntityUpdate = mustTemplate("entity_update.sql")
|
||||
sqlEntityRead = mustTemplate("entity_read.sql")
|
||||
|
||||
sqlEntityFolderInsert = mustTemplate("entity_folder_insert.sql")
|
||||
|
||||
sqlEntityRefFind = mustTemplate("entity_ref_find.sql")
|
||||
|
||||
sqlEntityLabelsDelete = mustTemplate("entity_labels_delete.sql")
|
||||
sqlEntityLabelsInsert = mustTemplate("entity_labels_insert.sql")
|
||||
|
||||
sqlKindVersionGet = mustTemplate("kind_version_get.sql")
|
||||
sqlKindVersionInc = mustTemplate("kind_version_inc.sql")
|
||||
sqlKindVersionInsert = mustTemplate("kind_version_insert.sql")
|
||||
sqlKindVersionLock = mustTemplate("kind_version_lock.sql")
|
||||
)
|
||||
|
||||
// TxOptions.
|
||||
var (
|
||||
ReadCommitted = &sql.TxOptions{
|
||||
Isolation: sql.LevelReadCommitted,
|
||||
}
|
||||
ReadCommittedRO = &sql.TxOptions{
|
||||
Isolation: sql.LevelReadCommitted,
|
||||
ReadOnly: true,
|
||||
}
|
||||
)
|
||||
|
||||
// SQLError is an error returned by the database, which includes additionally
|
||||
// debugging information about what was sent to the database.
|
||||
type SQLError struct {
|
||||
Err error
|
||||
CallType string // either Query, QueryRow or Exec
|
||||
Arguments []any
|
||||
ScanDest []any
|
||||
Query string
|
||||
RawQuery string
|
||||
}
|
||||
|
||||
func (e SQLError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (e SQLError) Error() string {
|
||||
return fmt.Sprintf("calling %s in database: %v", e.CallType, e.Err)
|
||||
}
|
||||
|
||||
func (e SQLError) Debug() string {
|
||||
scanDestStr := "(none)"
|
||||
if len(e.ScanDest) > 0 {
|
||||
format := "[%T" + strings.Repeat(", %T", len(e.ScanDest)-1) + "]"
|
||||
scanDestStr = fmt.Sprintf(format, e.ScanDest...)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("call %s in database: %v\n\tArguments (%d): %#v\n\t"+
|
||||
"Return Value Types (%d): %s\n\tExecuted Query: %s\n\tRaw SQL "+
|
||||
"Template Output: %s", e.CallType, e.Err, len(e.Arguments), e.Arguments,
|
||||
len(e.ScanDest), scanDestStr, e.Query, e.RawQuery)
|
||||
}
|
||||
|
||||
// entity_folder table requests.
|
||||
|
||||
type sqlEntityFolderInsertRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Items []*sqlEntityFolderInsertRequestItem
|
||||
@@ -59,12 +123,16 @@ type sqlEntityFolderInsertRequestItem struct {
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// entity_ref table requests.
|
||||
|
||||
type sqlEntityRefFindRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Request *entity.ReferenceRequest
|
||||
Entity *withSerialized
|
||||
returnsEntitySet
|
||||
}
|
||||
|
||||
// entity_labels table requests.
|
||||
|
||||
type sqlEntityLabelsInsertRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
GUID string
|
||||
@@ -77,29 +145,50 @@ type sqlEntityLabelsDeleteRequest struct {
|
||||
KeepLabels []string
|
||||
}
|
||||
|
||||
// entity_kind table requests.
|
||||
|
||||
type returnsKindVersion struct {
|
||||
ResourceVersion int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
func (r *returnsKindVersion) Results() (*returnsKindVersion, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type sqlKindVersionGetRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
Resource string
|
||||
*returnsKindVersion
|
||||
}
|
||||
|
||||
type sqlKindVersionLockRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
GroupVersion string
|
||||
Resource string
|
||||
ResourceVersion int64
|
||||
Group string
|
||||
Resource string
|
||||
*returnsKindVersion
|
||||
}
|
||||
|
||||
type sqlKindVersionIncRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
GroupVersion string
|
||||
Resource string
|
||||
ResourceVersion int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
type sqlKindVersionInsertRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
GroupVersion string
|
||||
Resource string
|
||||
Group string
|
||||
Resource string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// entity and entity_history tables requests.
|
||||
|
||||
type sqlEntityListFolderElementsRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Group string
|
||||
@@ -108,12 +197,16 @@ type sqlEntityListFolderElementsRequest struct {
|
||||
FolderInfo *folderInfo
|
||||
}
|
||||
|
||||
// sqlEntityReadRequest can be used to retrieve a row from either the "entity"
|
||||
// or the "entity_history" tables. In particular, don't use this template
|
||||
// directly. Instead, use the readEntity function, which provides all common use
|
||||
// cases and proper database deserialization.
|
||||
type sqlEntityReadRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Key *entity.Key
|
||||
ResourceVersion int64
|
||||
SelectForUpdate bool
|
||||
Entity *withSerialized
|
||||
returnsEntitySet
|
||||
}
|
||||
|
||||
type sqlEntityDeleteRequest struct {
|
||||
@@ -121,9 +214,21 @@ type sqlEntityDeleteRequest struct {
|
||||
Key *entity.Key
|
||||
}
|
||||
|
||||
type sqlEntityHistoryRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
//historyToken // TODO: coming in another PR
|
||||
returnsEntitySet
|
||||
}
|
||||
|
||||
type sqlEntityHistoryListRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
//hitoryListToken // TODO: coming in another PR
|
||||
returnsEntitySet
|
||||
}
|
||||
|
||||
type sqlEntityInsertRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Entity *withSerialized
|
||||
Entity *returnsEntity
|
||||
|
||||
// TableEntity, when true, means we will insert into table "entity", and
|
||||
// into table "entity_history" otherwise.
|
||||
@@ -132,33 +237,269 @@ type sqlEntityInsertRequest struct {
|
||||
|
||||
type sqlEntityUpdateRequest struct {
|
||||
*sqltemplate.SQLTemplate
|
||||
Entity *withSerialized
|
||||
Entity *returnsEntity
|
||||
}
|
||||
|
||||
// withSerialized provides access to the wire Entiity DTO as well as the
|
||||
// serialized version of some of its fields suitable to be read from or written
|
||||
// to the database.
|
||||
type withSerialized struct {
|
||||
*entity.Entity
|
||||
func newEmptyEntity() *entity.Entity {
|
||||
return &entity.Entity{
|
||||
// we need to allocate all internal pointer types so that they
|
||||
// are readily available to be populated in the template
|
||||
Origin: new(entity.EntityOriginInfo),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneEntity(src *entity.Entity) *entity.Entity {
|
||||
ret := newEmptyEntity()
|
||||
proto.Merge(ret, src)
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// returnsEntitySet can be embedded in a request struct to provide automatic set
|
||||
// returning of []*entity.Entity from the database, deserializing as needed. It
|
||||
// should be embedded as a value type.
|
||||
type returnsEntitySet struct {
|
||||
Entity *returnsEntity
|
||||
}
|
||||
|
||||
// newWithResults returns a new newWithResults.
|
||||
func newReturnsEntitySet() returnsEntitySet {
|
||||
return returnsEntitySet{
|
||||
Entity: newReturnsEntity(),
|
||||
}
|
||||
}
|
||||
|
||||
// Results is part of the implementation of sqltemplate.WithResults that
|
||||
// deserializes the database data into an internal *entity.Entity, and then
|
||||
// returns a deep copy of it.
|
||||
func (e returnsEntitySet) Results() (*entity.Entity, error) {
|
||||
ent, err := e.Entity.Results()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proto.Clone(ent).(*entity.Entity), nil
|
||||
}
|
||||
|
||||
// returnsEntity is a wrapper that aids with database (de)serialization. It
|
||||
// embeds a *entity.Entity to provide transparent access to all its fields, but
|
||||
// overrides the ones that need database (de)serialization. It should be a named
|
||||
// field in your request struct, with pointer type.
|
||||
type returnsEntity struct {
|
||||
*entity.Entity
|
||||
Labels []byte
|
||||
Fields []byte
|
||||
Errors []byte
|
||||
}
|
||||
|
||||
// TODO: remove once we start using these symbols. Prevents `unused` linter
|
||||
// until the next PR.
|
||||
var (
|
||||
_, _, _ = sqlEntityDelete, sqlEntityInsert, sqlEntityListFolderElements
|
||||
_, _, _ = sqlEntityUpdate, sqlEntityRead, sqlEntityFolderInsert
|
||||
_, _, _ = sqlEntityRefFind, sqlEntityLabelsDelete, sqlEntityLabelsInsert
|
||||
_, _, _ = sqlKindVersionInc, sqlKindVersionInsert, sqlKindVersionLock
|
||||
_, _ = sqlEntityFolderInsertRequest{}, sqlEntityFolderInsertRequestItem{}
|
||||
_, _ = sqlEntityRefFindRequest{}, sqlEntityLabelsInsertRequest{}
|
||||
_, _ = sqlEntityLabelsInsertRequest{}, sqlEntityLabelsDeleteRequest{}
|
||||
_, _ = sqlKindVersionLockRequest{}, sqlKindVersionIncRequest{}
|
||||
_, _ = sqlKindVersionInsertRequest{}, sqlEntityListFolderElementsRequest{}
|
||||
_, _ = sqlEntityReadRequest{}, sqlEntityDeleteRequest{}
|
||||
_, _ = sqlEntityInsertRequest{}, sqlEntityUpdateRequest{}
|
||||
_ = withSerialized{}
|
||||
)
|
||||
func newReturnsEntity() *returnsEntity {
|
||||
return &returnsEntity{
|
||||
Entity: newEmptyEntity(),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *returnsEntity) Results() (*entity.Entity, error) {
|
||||
if err := e.unmarshal(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return e.Entity, nil
|
||||
}
|
||||
|
||||
// marshal serializes the fields from the wire protocol representation so they
|
||||
// can be written to the database.
|
||||
func (e *returnsEntity) marshal() error {
|
||||
var err error
|
||||
|
||||
if len(e.Entity.Labels) == 0 {
|
||||
e.Labels = []byte{'{', '}'}
|
||||
} else {
|
||||
e.Labels, err = json.Marshal(e.Entity.Labels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize entity \"labels\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(e.Entity.Fields) == 0 {
|
||||
e.Fields = []byte{'{', '}'}
|
||||
} else {
|
||||
e.Fields, err = json.Marshal(e.Entity.Fields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize entity \"fields\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(e.Entity.Errors) == 0 {
|
||||
e.Errors = []byte{'[', ']'}
|
||||
} else {
|
||||
e.Errors, err = json.Marshal(e.Entity.Errors)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize entity \"errors\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unmarshal deserializes the fields in the database representation so they can
|
||||
// be written to the wire protocol.
|
||||
func (e *returnsEntity) unmarshal() error {
|
||||
if len(e.Labels) > 0 {
|
||||
if err := json.Unmarshal(e.Labels, &e.Entity.Labels); err != nil {
|
||||
return fmt.Errorf("deserialize entity \"labels\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(e.Fields) > 0 {
|
||||
if err := json.Unmarshal(e.Fields, &e.Entity.Fields); err != nil {
|
||||
return fmt.Errorf("deserialize entity \"fields\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(e.Errors) > 0 {
|
||||
if err := json.Unmarshal(e.Errors, &e.Entity.Errors); err != nil {
|
||||
return fmt.Errorf("deserialize entity \"errors\" field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readEntity(
|
||||
ctx context.Context,
|
||||
x db.ContextExecer,
|
||||
d sqltemplate.Dialect,
|
||||
k *entity.Key,
|
||||
asOfVersion int64,
|
||||
optimisticLocking bool,
|
||||
selectForUpdate bool,
|
||||
) (*returnsEntity, error) {
|
||||
if asOfVersion < 0 {
|
||||
asOfVersion = 0
|
||||
}
|
||||
if asOfVersion == 0 {
|
||||
optimisticLocking = false
|
||||
}
|
||||
|
||||
v := asOfVersion
|
||||
if optimisticLocking {
|
||||
// for optimistic locking, we will not ask for a specific version, but
|
||||
// instead retrieve the latest version from the table "entity" and
|
||||
// manually compare if it matches the given value of "asOfVersion".
|
||||
v = 0
|
||||
}
|
||||
|
||||
readReq := sqlEntityReadRequest{
|
||||
SQLTemplate: sqltemplate.New(d),
|
||||
Key: k,
|
||||
ResourceVersion: v,
|
||||
SelectForUpdate: selectForUpdate,
|
||||
returnsEntitySet: newReturnsEntitySet(),
|
||||
}
|
||||
ent, err := queryRow(ctx, x, sqlEntityRead, readReq)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read entity: %w", err)
|
||||
}
|
||||
|
||||
if ent.Action == entity.Entity_DELETED {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
if optimisticLocking && asOfVersion != 0 && ent.ResourceVersion != asOfVersion {
|
||||
return nil, ErrOptimisticLockingFailed
|
||||
}
|
||||
|
||||
return readReq.Entity, nil
|
||||
}
|
||||
|
||||
// kindVersionAtomicInc atomically increases the version of a kind within a
|
||||
// transaction.
|
||||
func kindVersionAtomicInc(ctx context.Context, x db.ContextExecer, d sqltemplate.Dialect, group, resource string) (newVersion int64, err error) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// 1. Lock the kind and get the latest version
|
||||
lockReq := sqlKindVersionLockRequest{
|
||||
SQLTemplate: sqltemplate.New(d),
|
||||
Group: group,
|
||||
Resource: resource,
|
||||
returnsKindVersion: new(returnsKindVersion),
|
||||
}
|
||||
kindv, err := queryRow(ctx, x, sqlKindVersionLock, lockReq)
|
||||
|
||||
// if there wasn't a row associated with the given kind, we create one with
|
||||
// version 1
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// NOTE: there is a marginal chance that we race with another writer
|
||||
// trying to create the same row. This is only possible when onboarding
|
||||
// a new (Group, Resource) to the cell, which should be very unlikely,
|
||||
// and the workaround is simply retrying. The alternative would be to
|
||||
// use INSERT ... ON CONFLICT DO UPDATE ..., but that creates a
|
||||
// requirement for support in Dialect only for this marginal case, but
|
||||
// we would rather keep Dialect as small as possible. Another
|
||||
// alternative is to simply check if the INSERT returns a DUPLICATE KEY
|
||||
// error and then retry the original SELECT, but that also adds some
|
||||
// complexity to the code. That would be preferrable to changing
|
||||
// Dialect, though. The current alternative, just retrying, seems to be
|
||||
// enough for now.
|
||||
insReq := sqlKindVersionInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(d),
|
||||
Group: group,
|
||||
Resource: resource,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err = exec(ctx, x, sqlKindVersionInsert, insReq); err != nil {
|
||||
return 0, fmt.Errorf("insert into kind_version: %w", err)
|
||||
}
|
||||
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("lock kind: %w", err)
|
||||
}
|
||||
|
||||
incReq := sqlKindVersionIncRequest{
|
||||
SQLTemplate: sqltemplate.New(d),
|
||||
Group: group,
|
||||
Resource: resource,
|
||||
ResourceVersion: kindv.ResourceVersion,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err = exec(ctx, x, sqlKindVersionInc, incReq); err != nil {
|
||||
return 0, fmt.Errorf("increase kind version: %w", err)
|
||||
}
|
||||
|
||||
return kindv.ResourceVersion + 1, nil
|
||||
}
|
||||
|
||||
// Template helpers.
|
||||
|
||||
// helperListSep is a helper that helps writing simpler loops in SQL templates.
|
||||
// Example usage:
|
||||
//
|
||||
// {{ $comma := listSep ", " }}
|
||||
// {{ range .Values }}
|
||||
// {{/* here we put "-" on each end to remove extra white space */}}
|
||||
// {{- call $comma -}}
|
||||
// {{ .Value }}
|
||||
// {{ end }}
|
||||
func helperListSep(sep string) func() string {
|
||||
var addSep bool
|
||||
|
||||
return func() string {
|
||||
if addSep {
|
||||
return sep
|
||||
}
|
||||
addSep = true
|
||||
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func helperJoin(sep string, elems ...string) string {
|
||||
return strings.Join(elems, sep)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
oldDB "github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl"
|
||||
@@ -25,113 +25,11 @@ func TestIsHealthy(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
s := setUpTestServer(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ent *entity.Entity
|
||||
errIsExpected bool
|
||||
statusIsExpected bool
|
||||
}{
|
||||
{
|
||||
"request with key and entity creator",
|
||||
&entity.Entity{
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
Namespace: "default",
|
||||
Name: "set-minimum-uid",
|
||||
Key: "/playlist.grafana.app/playlists/namespaces/default/set-minimum-uid",
|
||||
CreatedBy: "set-minimum-creator",
|
||||
Origin: &entity.EntityOriginInfo{},
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"request with no entity creator",
|
||||
&entity.Entity{
|
||||
Key: "/playlist.grafana.app/playlists/namespaces/default/set-only-key",
|
||||
},
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"request with no key",
|
||||
&entity.Entity{
|
||||
CreatedBy: "entity-creator",
|
||||
},
|
||||
true,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := entity.CreateEntityRequest{
|
||||
Entity: &entity.Entity{
|
||||
Key: tc.ent.Key,
|
||||
CreatedBy: tc.ent.CreatedBy,
|
||||
},
|
||||
}
|
||||
resp, err := s.Create(context.Background(), &req)
|
||||
|
||||
if tc.errIsExpected {
|
||||
require.Error(t, err)
|
||||
|
||||
if tc.statusIsExpected {
|
||||
require.Equal(t, entity.CreateEntityResponse_ERROR, resp.Status)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, entity.CreateEntityResponse_CREATED, resp.Status)
|
||||
require.NotNil(t, resp)
|
||||
require.Nil(t, resp.Error)
|
||||
|
||||
read, err := s.Read(context.Background(), &entity.ReadEntityRequest{
|
||||
Key: tc.ent.Key,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, read)
|
||||
|
||||
require.Greater(t, len(read.Guid), 0)
|
||||
require.Greater(t, read.ResourceVersion, int64(0))
|
||||
|
||||
expectedETag := createContentsHash(tc.ent.Body, tc.ent.Meta, tc.ent.Status)
|
||||
require.Equal(t, expectedETag, read.ETag)
|
||||
require.Equal(t, tc.ent.Origin, read.Origin)
|
||||
require.Equal(t, tc.ent.Group, read.Group)
|
||||
require.Equal(t, tc.ent.Resource, read.Resource)
|
||||
require.Equal(t, tc.ent.Namespace, read.Namespace)
|
||||
require.Equal(t, tc.ent.Name, read.Name)
|
||||
require.Equal(t, tc.ent.Subresource, read.Subresource)
|
||||
require.Equal(t, tc.ent.GroupVersion, read.GroupVersion)
|
||||
require.Equal(t, tc.ent.Key, read.Key)
|
||||
require.Equal(t, tc.ent.Folder, read.Folder)
|
||||
require.Equal(t, tc.ent.Meta, read.Meta)
|
||||
require.Equal(t, tc.ent.Body, read.Body)
|
||||
require.Equal(t, tc.ent.Status, read.Status)
|
||||
require.Equal(t, tc.ent.Title, read.Title)
|
||||
require.Equal(t, tc.ent.Size, read.Size)
|
||||
require.Greater(t, read.CreatedAt, int64(0))
|
||||
require.Equal(t, tc.ent.CreatedBy, read.CreatedBy)
|
||||
require.Equal(t, tc.ent.UpdatedAt, read.UpdatedAt)
|
||||
require.Equal(t, tc.ent.UpdatedBy, read.UpdatedBy)
|
||||
require.Equal(t, tc.ent.Description, read.Description)
|
||||
require.Equal(t, tc.ent.Slug, read.Slug)
|
||||
require.Equal(t, tc.ent.Message, read.Message)
|
||||
require.Equal(t, tc.ent.Labels, read.Labels)
|
||||
require.Equal(t, tc.ent.Fields, read.Fields)
|
||||
require.Equal(t, tc.ent.Errors, read.Errors)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setUpTestServer(t *testing.T) entity.EntityStoreServer {
|
||||
sqlStore, cfg := db.InitTestDBWithCfg(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
sqlStore, cfg := oldDB.InitTestDBWithCfg(t)
|
||||
|
||||
entityDB, err := dbimpl.ProvideEntityDB(
|
||||
sqlStore,
|
||||
@@ -150,3 +48,18 @@ func setUpTestServer(t *testing.T) entity.EntityStoreServer {
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}
|
||||
|
||||
// TODO: remove all the following once the Proposal 1 for Consistent Resource
|
||||
// Version is finished.
|
||||
var (
|
||||
_ = parseAllSortBy
|
||||
_ = countTrue
|
||||
_ = query[any]
|
||||
_ = sqlEntityHistory
|
||||
_ = sqlEntityRefFind
|
||||
_ = sqlKindVersionGet
|
||||
_ = sqlEntityRefFindRequest{}
|
||||
_ = sqlKindVersionGetRequest{}
|
||||
_ = sqlEntityHistoryRequest{}
|
||||
_ = sqlEntityHistoryListRequest{}
|
||||
)
|
||||
|
||||
@@ -1,18 +1,80 @@
|
||||
package sqltemplate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Args errors.
|
||||
var (
|
||||
ErrInvalidArgList = errors.New("invalid arglist")
|
||||
)
|
||||
|
||||
// Args keeps the data that needs to be passed to the engine for execution in
|
||||
// the right order. Add it to your data types passed to SQLTemplate, either by
|
||||
// embedding or with a named struct field if its Arg method would clash with
|
||||
// another struct field.
|
||||
type Args []any
|
||||
type Args struct {
|
||||
d Dialect
|
||||
values []any
|
||||
}
|
||||
|
||||
func NewArgs(d Dialect) *Args {
|
||||
return &Args{
|
||||
d: d,
|
||||
}
|
||||
}
|
||||
|
||||
// Arg can be called from within templates to pass arguments to the SQL driver
|
||||
// to use in the execution of the query.
|
||||
func (a *Args) Arg(x any) string {
|
||||
*a = append(*a, x)
|
||||
return "?"
|
||||
a.values = append(a.values, x)
|
||||
|
||||
return a.d.ArgPlaceholder(len(a.values))
|
||||
}
|
||||
|
||||
func (a *Args) GetArgs() Args {
|
||||
return *a
|
||||
// ArgList returns a comma separated list of `?` placeholders for each element
|
||||
// in the provided slice argument, calling Arg for each of them.
|
||||
// Example struct:
|
||||
//
|
||||
// type sqlMyRequest struct {
|
||||
// *sqltemplate.SQLTemplate
|
||||
// IDs []int64
|
||||
// }
|
||||
//
|
||||
// Example usage in a SQL template:
|
||||
//
|
||||
// DELETE FROM {{ .Ident "mytab" }}
|
||||
// WHERE id IN ( {{ argList . .IDs }} )
|
||||
// ;
|
||||
func (a *Args) ArgList(slice reflect.Value) (string, error) {
|
||||
if !slice.IsValid() || slice.Kind() != reflect.Slice {
|
||||
return "", ErrInvalidArgList
|
||||
}
|
||||
sliceLen := slice.Len()
|
||||
if sliceLen == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(3*sliceLen - 2) // the list will be ?, ?, ?
|
||||
for i, l := 0, slice.Len(); i < l; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(a.Arg(slice.Index(i).Interface()))
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func (a *Args) GetArgs() []any {
|
||||
return a.values
|
||||
}
|
||||
|
||||
type ArgsIface interface {
|
||||
Arg(x any) string
|
||||
ArgList(slice reflect.Value) (string, error)
|
||||
GetArgs() []any
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestArgs_Arg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
a := new(Args)
|
||||
a := NewArgs(MySQL)
|
||||
|
||||
shouldBeQuestionMark(t, a.Arg(0))
|
||||
shouldBeQuestionMark(t, a.Arg(1))
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqltemplate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,12 @@ type Dialect interface {
|
||||
// names are all examples of identifiers.
|
||||
Ident(string) (string, error)
|
||||
|
||||
// ArgPlaceholder returns a safe argument suitable to be used in a SQL
|
||||
// prepared statement for the argNum-eth argument passed in execution. The
|
||||
// SQL92 Standard specifies the question mark ('?') should be used in all
|
||||
// cases, but some implementations differ.
|
||||
ArgPlaceholder(argNum int) string
|
||||
|
||||
// SelectFor parses and returns the given row-locking clause for a SELECT
|
||||
// statement. If the clause is invalid it returns an error. Implementations
|
||||
// of this method should use ParseRowLockingClause.
|
||||
@@ -97,3 +104,18 @@ func (standardIdent) Ident(s string) (string, error) {
|
||||
}
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`, nil
|
||||
}
|
||||
|
||||
type argPlaceholderFunc func(int) string
|
||||
|
||||
func (f argPlaceholderFunc) ArgPlaceholder(argNum int) string {
|
||||
return f(argNum)
|
||||
}
|
||||
|
||||
var (
|
||||
argFmtSQL92 = argPlaceholderFunc(func(int) string {
|
||||
return "?"
|
||||
})
|
||||
argFmtPositional = argPlaceholderFunc(func(argNum int) string {
|
||||
return "$" + strconv.Itoa(argNum)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ package sqltemplate
|
||||
// https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_ansi_quotes
|
||||
var MySQL = mysql{
|
||||
rowLockingClauseAll: true,
|
||||
argPlaceholderFunc: argFmtSQL92,
|
||||
}
|
||||
|
||||
var _ Dialect = MySQL
|
||||
@@ -14,4 +15,5 @@ var _ Dialect = MySQL
|
||||
type mysql struct {
|
||||
standardIdent
|
||||
rowLockingClauseAll
|
||||
argPlaceholderFunc
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
// PostgreSQL is an implementation of Dialect for the PostgreSQL DMBS.
|
||||
var PostgreSQL = postgresql{
|
||||
rowLockingClauseAll: true,
|
||||
argPlaceholderFunc: argFmtPositional,
|
||||
}
|
||||
|
||||
var _ Dialect = PostgreSQL
|
||||
@@ -20,6 +21,7 @@ var (
|
||||
type postgresql struct {
|
||||
standardIdent
|
||||
rowLockingClauseAll
|
||||
argPlaceholderFunc
|
||||
}
|
||||
|
||||
func (p postgresql) Ident(s string) (string, error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package sqltemplate
|
||||
// SQLite is an implementation of Dialect for the SQLite DMBS.
|
||||
var SQLite = sqlite{
|
||||
rowLockingClauseAll: false,
|
||||
argPlaceholderFunc: argFmtSQL92,
|
||||
}
|
||||
|
||||
var _ Dialect = SQLite
|
||||
@@ -12,4 +13,5 @@ type sqlite struct {
|
||||
// https://www.sqlite.org/lang_keywords.html
|
||||
standardIdent
|
||||
rowLockingClauseAll
|
||||
argPlaceholderFunc
|
||||
}
|
||||
|
||||
@@ -103,15 +103,15 @@ func Example() {
|
||||
|
||||
// Assuming that we have a *sql.DB object named "db", we could now make our
|
||||
// query with:
|
||||
// row := db.QueryRowContext(ctx, query, queryData.Args...)
|
||||
// row := db.QueryRowContext(ctx, query, queryData.GetArgs()...)
|
||||
// // and check row.Err() here
|
||||
|
||||
// As we're not actually running a database in this example, let's verify
|
||||
// that we find our arguments populated as expected instead:
|
||||
if len(queryData.Args) != 1 {
|
||||
if len(queryData.GetArgs()) != 1 {
|
||||
panic(fmt.Sprintf("unexpected number of args: %#v", queryData.Args))
|
||||
}
|
||||
id, ok := queryData.Args[0].(int)
|
||||
id, ok := queryData.GetArgs()[0].(int)
|
||||
if !ok || id != queryData.Request.ID {
|
||||
panic(fmt.Sprintf("unexpected args: %#v", queryData.Args))
|
||||
}
|
||||
@@ -119,25 +119,25 @@ func Example() {
|
||||
// In your code you would now have "row" populated with the row data,
|
||||
// assuming that the operation succeeded, so you would now scan the row data
|
||||
// abd populate the values of our response:
|
||||
// err := row.Scan(queryData.ScanDest...)
|
||||
// err := row.Scan(queryData.GetScanDest()...)
|
||||
// // and check err here
|
||||
|
||||
// Again, as we're not actually running a database in this example, we will
|
||||
// instead run the code to assert that queryData.ScanDest was populated with
|
||||
// the expected data, which should be pointers to each of the fields of
|
||||
// Response so that the Scan method can write to them:
|
||||
if len(queryData.ScanDest) != 3 {
|
||||
if len(queryData.GetScanDest()) != 3 {
|
||||
panic(fmt.Sprintf("unexpected number of scan dest: %#v", queryData.ScanDest))
|
||||
}
|
||||
idPtr, ok := queryData.ScanDest[0].(*int)
|
||||
idPtr, ok := queryData.GetScanDest()[0].(*int)
|
||||
if !ok || idPtr != &queryData.Response.ID {
|
||||
panic(fmt.Sprintf("unexpected response 'id' pointer: %#v", queryData.ScanDest))
|
||||
}
|
||||
typePtr, ok := queryData.ScanDest[1].(*string)
|
||||
typePtr, ok := queryData.GetScanDest()[1].(*string)
|
||||
if !ok || typePtr != &queryData.Response.Type {
|
||||
panic(fmt.Sprintf("unexpected response 'type' pointer: %#v", queryData.ScanDest))
|
||||
}
|
||||
namePtr, ok := queryData.ScanDest[2].(*string)
|
||||
namePtr, ok := queryData.GetScanDest()[2].(*string)
|
||||
if !ok || namePtr != &queryData.Response.Name {
|
||||
panic(fmt.Sprintf("unexpected response 'name' pointer: %#v", queryData.ScanDest))
|
||||
}
|
||||
|
||||
@@ -5,18 +5,25 @@ import (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type ScanDest []any
|
||||
type ScanDest struct {
|
||||
values []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())
|
||||
i.values = append(i.values, v.Addr().Interface())
|
||||
|
||||
return colName, nil
|
||||
}
|
||||
|
||||
func (i *ScanDest) GetScanDest() ScanDest {
|
||||
return *i
|
||||
func (i *ScanDest) GetScanDest() []any {
|
||||
return i.values
|
||||
}
|
||||
|
||||
type ScanDestIface interface {
|
||||
Into(v reflect.Value, colName string) (string, error)
|
||||
GetScanDest() []any
|
||||
}
|
||||
|
||||
@@ -23,13 +23,15 @@ func TestScanDest_Into(t *testing.T) {
|
||||
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 {
|
||||
v := d.GetScanDest()
|
||||
if err != nil || colName != "some int" || len(v) != 1 || v[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 {
|
||||
v = d.GetScanDest()
|
||||
if err != nil || colName != "some byte" || len(v) != 2 || v[1] != &data.Y {
|
||||
t.Fatalf("unexpected outcome, got colname %q, err: %v, scan dest: %#v",
|
||||
colName, err, d)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
package sqltemplate
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// SQLTemplate provides comprehensive support for SQL templating, handling
|
||||
// dialect traits, execution arguments and scanning arguments.
|
||||
type SQLTemplate struct {
|
||||
Dialect
|
||||
Args
|
||||
ScanDest
|
||||
}
|
||||
|
||||
// New returns a nee *SQLTemplate that will use the given dialect.
|
||||
func New(d Dialect) *SQLTemplate {
|
||||
return &SQLTemplate{
|
||||
Args: Args{
|
||||
d: d,
|
||||
},
|
||||
Dialect: d,
|
||||
}
|
||||
}
|
||||
|
||||
// SQLTemplateIface can be used as argument in general purpose utilities
|
||||
// expecting a struct embedding *SQLTemplate.
|
||||
type SQLTemplateIface interface {
|
||||
Dialect
|
||||
GetArgs() Args
|
||||
GetScanDest() ScanDest
|
||||
ArgsIface
|
||||
ScanDestIface
|
||||
}
|
||||
|
||||
// WithResults has an additional method suited for structs embedding
|
||||
// *SQLTemplate and returning a set of rows.
|
||||
type WithResults[T any] interface {
|
||||
SQLTemplateIface
|
||||
|
||||
// Results returns the results of the query. If the query is expected to
|
||||
// return a set of rows, then it should be a deep copy of the internal
|
||||
// results, so that it can be called multiple times to get the different
|
||||
// values.
|
||||
Results() (T, error)
|
||||
}
|
||||
|
||||
// Execute is a trivial utility to execute and return the results of any
|
||||
@@ -33,3 +54,38 @@ func Execute(t *template.Template, data any) (string, error) {
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// FormatSQL is an opinionated formatter for SQL template output that returns
|
||||
// the code as a oneliner. It can be used to reduce the final code length, for
|
||||
// debugging, and testing. It is not a propoer and full-fledged SQL parser, so
|
||||
// it makes the following assumptions, which are also good practices for writing
|
||||
// your SQL templates:
|
||||
// 1. There are no SQL comments. Consider adding your comments as template
|
||||
// comments instead (i.e. "{{/* this is a template comment */}}").
|
||||
// 2. There are no multiline strings, and strings do not contain consecutive
|
||||
// spaces. Code looking like this is already a smell. Avoid string literals,
|
||||
// pass them as arguments so they can be appropriately escaped by the
|
||||
// corresponding driver. And identifiers with white space should be avoided
|
||||
// in all cases as well.
|
||||
func FormatSQL(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
for _, f := range formatREs {
|
||||
q = f.re.ReplaceAllString(q, f.replacement)
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
type reFormatting struct {
|
||||
re *regexp.Regexp
|
||||
replacement string
|
||||
}
|
||||
|
||||
var formatREs = []reFormatting{
|
||||
{re: regexp.MustCompile(`\s+`), replacement: " "},
|
||||
{re: regexp.MustCompile(` ?([+-/*=<>%!~]+) ?`), replacement: " $1 "},
|
||||
{re: regexp.MustCompile(`([([{]) `), replacement: "$1"},
|
||||
{re: regexp.MustCompile(` ([)\]}])`), replacement: "$1"},
|
||||
{re: regexp.MustCompile(` ?, ?`), replacement: ", "},
|
||||
{re: regexp.MustCompile(` ?([;.:]) ?`), replacement: "$1"},
|
||||
}
|
||||
|
||||
+18
-14
@@ -3,16 +3,16 @@
|
||||
// Frame[0]
|
||||
// Name:
|
||||
// Dimensions: 7 Fields by 4 Rows
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+---------------------------------------------------------------------------------------------------------+
|
||||
// | Name: UID | Name: name | Name: slug | Name: depth | Name: left | Name: right | Name: tree |
|
||||
// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: |
|
||||
// | Type: []string | Type: []string | Type: []string | Type: []int32 | Type: []int32 | Type: []int32 | Type: []json.RawMessage |
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+---------------------------------------------------------------------------------------------------------+
|
||||
// | | Root | | 0 | 1 | 8 | [] |
|
||||
// | A | A | /a/ | 1 | 2 | 5 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/"}] |
|
||||
// | AA | AA | /a/aa/ | 2 | 3 | 4 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/"},{"guid":"GAA","uid":"AA","name":"AA","slug":"/a/aa/"}] |
|
||||
// | B | B | /b/ | 1 | 6 | 7 | [{"guid":"GB","uid":"B","name":"B","slug":"/b/"}] |
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+---------------------------------------------------------------------------------------------------------+
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||
// | Name: UID | Name: name | Name: slug | Name: depth | Name: left | Name: right | Name: tree |
|
||||
// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: |
|
||||
// | Type: []string | Type: []string | Type: []string | Type: []int32 | Type: []int32 | Type: []int32 | Type: []json.RawMessage |
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||
// | | Root | | 0 | 1 | 8 | [] |
|
||||
// | A | A | /a/ | 1 | 2 | 5 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/","ParentUID":""}] |
|
||||
// | AA | AA | /a/aa/ | 2 | 3 | 4 | [{"guid":"GA","uid":"A","name":"A","slug":"/a/","ParentUID":""},{"guid":"GAA","uid":"AA","name":"AA","slug":"/a/aa/","ParentUID":"A"}] |
|
||||
// | B | B | /b/ | 1 | 6 | 7 | [{"guid":"GB","uid":"B","name":"B","slug":"/b/","ParentUID":""}] |
|
||||
// +----------------+----------------+----------------+---------------+---------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||
//
|
||||
//
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
@@ -118,7 +118,8 @@
|
||||
"guid": "GA",
|
||||
"uid": "A",
|
||||
"name": "A",
|
||||
"slug": "/a/"
|
||||
"slug": "/a/",
|
||||
"ParentUID": ""
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -126,13 +127,15 @@
|
||||
"guid": "GA",
|
||||
"uid": "A",
|
||||
"name": "A",
|
||||
"slug": "/a/"
|
||||
"slug": "/a/",
|
||||
"ParentUID": ""
|
||||
},
|
||||
{
|
||||
"guid": "GAA",
|
||||
"uid": "AA",
|
||||
"name": "AA",
|
||||
"slug": "/a/aa/"
|
||||
"slug": "/a/aa/",
|
||||
"ParentUID": "A"
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -140,7 +143,8 @@
|
||||
"guid": "GB",
|
||||
"uid": "B",
|
||||
"name": "B",
|
||||
"slug": "/b/"
|
||||
"slug": "/b/",
|
||||
"ParentUID": ""
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
|
||||
)
|
||||
|
||||
func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequest) (*entity.UpdateEntityResponse, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "storage_server.Update")
|
||||
defer span.End()
|
||||
|
||||
key, err := entity.ParseKey(r.Entity.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update entity: parse entity key: %w", err)
|
||||
}
|
||||
|
||||
updatedBy, err := getCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update entity: get user from context: %w", err)
|
||||
}
|
||||
|
||||
ret := new(entity.UpdateEntityResponse)
|
||||
|
||||
err = s.sqlDB.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error {
|
||||
// Pre-locking: get the latest version of the entity
|
||||
oldEntity, err := readEntity(ctx, tx, s.sqlDialect, key, r.PreviousVersion, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// build the entity from the request and the old data
|
||||
newEntity, err := entityForUpdate(updatedBy, oldEntity.Entity, r.Entity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("")
|
||||
}
|
||||
keepLabels, insertLabels := diffLabels(oldEntity.Entity.Labels, r.Entity.Labels)
|
||||
|
||||
// Pre-locking: delete old labels
|
||||
if len(keepLabels) > 0 {
|
||||
delLabelsReq := sqlEntityLabelsDeleteRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
GUID: oldEntity.Guid,
|
||||
KeepLabels: keepLabels,
|
||||
}
|
||||
_, err = exec(ctx, tx, sqlEntityLabelsDelete, delLabelsReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete old labels: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-locking: insert new labels
|
||||
if len(insertLabels) > 0 {
|
||||
insLabelsReq := sqlEntityLabelsInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
GUID: oldEntity.Guid,
|
||||
Labels: insertLabels,
|
||||
}
|
||||
_, err = exec(ctx, tx, sqlEntityLabelsInsert, insLabelsReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert new labels: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// up to this point, we have done all the work possible before having to
|
||||
// lock kind_version
|
||||
|
||||
// 1. Atomically increpement resource version for this kind
|
||||
newVersion, err := kindVersionAtomicInc(ctx, tx, s.sqlDialect, key.Group, key.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newEntity.ResourceVersion = newVersion
|
||||
|
||||
// 2. Update entity
|
||||
updEntityReq := sqlEntityUpdateRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: newEntity,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityUpdate, updEntityReq); err != nil {
|
||||
return fmt.Errorf("update entity: %w", err)
|
||||
}
|
||||
|
||||
// 3. Insert into entity history
|
||||
insEntity := sqlEntityInsertRequest{
|
||||
SQLTemplate: sqltemplate.New(s.sqlDialect),
|
||||
Entity: newEntity,
|
||||
}
|
||||
if _, err = exec(ctx, tx, sqlEntityInsert, insEntity); err != nil {
|
||||
return fmt.Errorf("insert into entity_history: %w", err)
|
||||
}
|
||||
|
||||
// 4. Rebuild the whole folder tree structure if we're updating a folder
|
||||
if newEntity.Group == folder.GROUP && newEntity.Resource == folder.RESOURCE {
|
||||
if err = s.updateFolderTree(ctx, tx, key.Namespace); err != nil {
|
||||
return fmt.Errorf("rebuild folder tree structure: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// success
|
||||
ret.Entity = newEntity.Entity
|
||||
ret.Status = entity.UpdateEntityResponse_UPDATED
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// TODO: should we define the "Error" field here and how? (i.e. how
|
||||
// to determine what information can be disclosed to the user?)
|
||||
return nil, fmt.Errorf("update entity: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func diffLabels(oldLabels, newLabels map[string]string) (keepLabels []string, insertLabels map[string]string) {
|
||||
insertLabels = maps.Clone(newLabels)
|
||||
for oldk, oldv := range oldLabels {
|
||||
newv, ok := insertLabels[oldk]
|
||||
if ok && oldv == newv {
|
||||
keepLabels = append(keepLabels, oldk)
|
||||
delete(insertLabels, oldk)
|
||||
}
|
||||
}
|
||||
|
||||
return keepLabels, insertLabels
|
||||
}
|
||||
|
||||
// entityForUpdate populates a *returnsEntity taking the relevant parts from
|
||||
// the requested update and keeping the necessary values from the old one.
|
||||
func entityForUpdate(updatedBy string, oldEntity, newEntity *entity.Entity) (*returnsEntity, error) {
|
||||
newOrigin := ptrOr(newEntity.Origin)
|
||||
oldOrigin := ptrOr(oldEntity.Origin)
|
||||
|
||||
ret := &returnsEntity{
|
||||
Entity: &entity.Entity{
|
||||
Guid: oldEntity.Guid, // read-only
|
||||
// ResourceVersion is later set after reading `kind_version` table
|
||||
|
||||
Key: oldEntity.Key, // read-only
|
||||
|
||||
Group: oldEntity.Group, // read-only
|
||||
GroupVersion: cmp.Or(newEntity.GroupVersion, oldEntity.GroupVersion),
|
||||
Resource: oldEntity.Resource, // read-only
|
||||
Namespace: oldEntity.Namespace, // read-only
|
||||
Name: oldEntity.Name, // read-only
|
||||
|
||||
Folder: cmp.Or(newEntity.Folder, oldEntity.Folder),
|
||||
|
||||
Meta: sliceOr(newEntity.Meta, oldEntity.Meta),
|
||||
Body: sliceOr(newEntity.Body, oldEntity.Body),
|
||||
Status: sliceOr(newEntity.Status, oldEntity.Status),
|
||||
|
||||
Size: int64(cmp.Or(len(newEntity.Body), len(oldEntity.Body))),
|
||||
ETag: cmp.Or(newEntity.ETag, oldEntity.ETag),
|
||||
|
||||
CreatedAt: oldEntity.CreatedAt, // read-only
|
||||
CreatedBy: oldEntity.CreatedBy, // read-only
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
UpdatedBy: updatedBy,
|
||||
|
||||
Origin: &entity.EntityOriginInfo{
|
||||
Source: cmp.Or(newOrigin.Source, oldOrigin.Source),
|
||||
Key: cmp.Or(newOrigin.Key, oldOrigin.Key),
|
||||
Time: cmp.Or(newOrigin.Time, oldOrigin.Time),
|
||||
},
|
||||
|
||||
Title: cmp.Or(newEntity.Title, oldEntity.Title),
|
||||
Slug: cmp.Or(newEntity.Slug, oldEntity.Slug),
|
||||
Description: cmp.Or(newEntity.Description, oldEntity.Description),
|
||||
|
||||
Message: cmp.Or(newEntity.Message, oldEntity.Message),
|
||||
Labels: mapOr(newEntity.Labels, oldEntity.Labels),
|
||||
Fields: mapOr(newEntity.Fields, oldEntity.Fields),
|
||||
Errors: newEntity.Errors,
|
||||
|
||||
Action: entity.Entity_UPDATED,
|
||||
},
|
||||
}
|
||||
|
||||
if len(newEntity.Body) != 0 ||
|
||||
len(newEntity.Meta) != 0 ||
|
||||
len(newEntity.Status) != 0 {
|
||||
ret.ETag = createETag(ret.Body, ret.Meta, ret.Status)
|
||||
}
|
||||
|
||||
if err := ret.marshal(); err != nil {
|
||||
return nil, fmt.Errorf("serialize entity data for db: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
package sqlstash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/services/store"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/db"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity/sqlstash/sqltemplate"
|
||||
)
|
||||
|
||||
func createContentsHash(body []byte, meta []byte, status []byte) string {
|
||||
func createETag(body []byte, meta []byte, status []byte) string {
|
||||
h := md5.New()
|
||||
_, _ = h.Write(meta)
|
||||
_, _ = h.Write(body)
|
||||
@@ -13,3 +22,168 @@ func createContentsHash(body []byte, meta []byte, status []byte) string {
|
||||
hash := h.Sum(nil)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// getCurrentUser returns a string identifying the user making a request with
|
||||
// the given context.
|
||||
func getCurrentUser(ctx context.Context) (string, error) {
|
||||
user, err := appcontext.User(ctx)
|
||||
if err != nil || user == nil {
|
||||
return "", fmt.Errorf("%w: %w", ErrUserNotFoundInContext, err)
|
||||
}
|
||||
|
||||
return store.GetUserIDString(user), nil
|
||||
}
|
||||
|
||||
// ptrOr returns the first non-nil pointer in the least or a new non-nil
|
||||
// pointer.
|
||||
func ptrOr[P ~*E, E any](ps ...P) P {
|
||||
for _, p := range ps {
|
||||
if p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
return P(new(E))
|
||||
}
|
||||
|
||||
// sliceOr returns the first slice that has at least one element, or a non-nil
|
||||
// empty slice.
|
||||
func sliceOr[S ~[]E, E comparable](vals ...S) S {
|
||||
for _, s := range vals {
|
||||
if len(s) > 0 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return S{}
|
||||
}
|
||||
|
||||
// mapOr returns the first map that has at least one element, or a non-nil empty
|
||||
// map.
|
||||
func mapOr[M ~map[K]V, K comparable, V any](vals ...M) M {
|
||||
for _, m := range vals {
|
||||
if len(m) > 0 {
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
return M{}
|
||||
}
|
||||
|
||||
// countTrue returns the number of true values in its arguments.
|
||||
func countTrue(bools ...bool) uint64 {
|
||||
var ret uint64
|
||||
for _, b := range bools {
|
||||
if b {
|
||||
ret++
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// query uses `req` as input and output for a zero or more row-returning query
|
||||
// generated with `tmpl`, and executed in `x`.
|
||||
func query[T any](ctx context.Context, x db.ContextExecer, tmpl *template.Template, req sqltemplate.WithResults[T]) ([]T, error) {
|
||||
rawQuery, err := sqltemplate.Execute(tmpl, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
query := sqltemplate.FormatSQL(rawQuery)
|
||||
|
||||
rows, err := x.QueryContext(ctx, query, req.GetArgs()...)
|
||||
if err != nil {
|
||||
return nil, SQLError{
|
||||
Err: err,
|
||||
CallType: "Query",
|
||||
Arguments: req.GetArgs(),
|
||||
ScanDest: req.GetScanDest(),
|
||||
Query: query,
|
||||
RawQuery: rawQuery,
|
||||
}
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck
|
||||
|
||||
var ret []T
|
||||
for rows.Next() {
|
||||
res, err := scanRow(rows, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret = append(ret, res)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows err: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// queryRow uses `req` as input and output for a single-row returning query
|
||||
// generated with `tmpl`, and executed in `x`.
|
||||
func queryRow[T any](ctx context.Context, x db.ContextExecer, tmpl *template.Template, req sqltemplate.WithResults[T]) (T, error) {
|
||||
var zero T
|
||||
|
||||
rawQuery, err := sqltemplate.Execute(tmpl, req)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
query := sqltemplate.FormatSQL(rawQuery)
|
||||
|
||||
row := x.QueryRowContext(ctx, query, req.GetArgs()...)
|
||||
if err := row.Err(); err != nil {
|
||||
return zero, SQLError{
|
||||
Err: err,
|
||||
CallType: "QueryRow",
|
||||
Arguments: req.GetArgs(),
|
||||
ScanDest: req.GetScanDest(),
|
||||
Query: query,
|
||||
RawQuery: rawQuery,
|
||||
}
|
||||
}
|
||||
|
||||
return scanRow(row, req)
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanRow is used on *sql.Row and *sql.Rows, and is factored out here not to
|
||||
// improving code reuse, but rather for ease of testing.
|
||||
func scanRow[T any](sc scanner, req sqltemplate.WithResults[T]) (zero T, err error) {
|
||||
if err = sc.Scan(req.GetScanDest()...); err != nil {
|
||||
return zero, fmt.Errorf("row scan: %w", err)
|
||||
}
|
||||
|
||||
res, err := req.Results()
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("row results: %w", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// exec uses `req` as input for a non-data returning query generated with
|
||||
// `tmpl`, and executed in `x`.
|
||||
func exec(ctx context.Context, x db.ContextExecer, tmpl *template.Template, req sqltemplate.SQLTemplateIface) (sql.Result, error) {
|
||||
rawQuery, err := sqltemplate.Execute(tmpl, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
query := sqltemplate.FormatSQL(rawQuery)
|
||||
|
||||
res, err := x.ExecContext(ctx, query, req.GetArgs()...)
|
||||
if err != nil {
|
||||
return nil, SQLError{
|
||||
Err: err,
|
||||
CallType: "Exec",
|
||||
Arguments: req.GetArgs(),
|
||||
Query: query,
|
||||
RawQuery: rawQuery,
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user