mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Refactor search (#23550)
Co-Authored-By: Arve Knudsen <arve.knudsen@grafana.com> Co-Authored-By: Leonard Gram <leonard.gram@grafana.com>
This commit is contained in:
parent
e5dd7efdee
commit
55c306eb6d
@ -330,6 +330,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
})
|
||||
|
||||
// Search
|
||||
apiRoute.Get("/search/sorting", Wrap(hs.ListSortOptions))
|
||||
apiRoute.Get("/search/", Wrap(Search))
|
||||
|
||||
// metrics
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"github.com/grafana/grafana/pkg/services/search"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -67,6 +68,7 @@ type HTTPServer struct {
|
||||
License models.Licensing `inject:""`
|
||||
BackendPluginManager backendplugin.Manager `inject:""`
|
||||
PluginManager *plugins.PluginManager `inject:""`
|
||||
SearchService *search.SearchService `inject:""`
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) Init() error {
|
||||
|
@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
@ -16,6 +18,7 @@ func Search(c *models.ReqContext) Response {
|
||||
limit := c.QueryInt64("limit")
|
||||
page := c.QueryInt64("page")
|
||||
dashboardType := c.Query("type")
|
||||
sort := c.Query("sort")
|
||||
permission := models.PERMISSION_VIEW
|
||||
|
||||
if limit > 5000 {
|
||||
@ -54,6 +57,7 @@ func Search(c *models.ReqContext) Response {
|
||||
Type: dashboardType,
|
||||
FolderIds: folderIDs,
|
||||
Permission: permission,
|
||||
Sort: sort,
|
||||
}
|
||||
|
||||
err := bus.Dispatch(&searchQuery)
|
||||
@ -64,3 +68,20 @@ func Search(c *models.ReqContext) Response {
|
||||
c.TimeRequest(metrics.MApiDashboardSearch)
|
||||
return JSON(200, searchQuery.Result)
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) ListSortOptions(c *models.ReqContext) Response {
|
||||
opts := hs.SearchService.SortOptions()
|
||||
|
||||
res := []util.DynMap{}
|
||||
for _, o := range opts {
|
||||
res = append(res, util.DynMap{
|
||||
"name": o.Name,
|
||||
"displayName": o.DisplayName,
|
||||
"description": o.Description,
|
||||
})
|
||||
}
|
||||
|
||||
return JSON(http.StatusOK, util.DynMap{
|
||||
"sortOptions": res,
|
||||
})
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"sort"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
@ -24,6 +26,7 @@ type Query struct {
|
||||
DashboardIds []int64
|
||||
FolderIds []int64
|
||||
Permission models.PermissionType
|
||||
Sort string
|
||||
|
||||
Result HitList
|
||||
}
|
||||
@ -41,37 +44,63 @@ type FindPersistedDashboardsQuery struct {
|
||||
Page int64
|
||||
Permission models.PermissionType
|
||||
|
||||
FeatureSearch2 bool
|
||||
SortBy searchstore.FilterOrderBy
|
||||
|
||||
Result HitList
|
||||
}
|
||||
|
||||
type SearchService struct {
|
||||
Bus bus.Bus `inject:""`
|
||||
Bus bus.Bus `inject:""`
|
||||
Cfg *setting.Cfg `inject:""`
|
||||
|
||||
sortOptions map[string]SortOption
|
||||
}
|
||||
|
||||
func (s *SearchService) Init() error {
|
||||
s.Bus.AddHandler(s.searchHandler)
|
||||
s.sortOptions = map[string]SortOption{
|
||||
sortAlphaAsc.Name: sortAlphaAsc,
|
||||
sortAlphaDesc.Name: sortAlphaDesc,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchService) searchHandler(query *Query) error {
|
||||
sortOpt, exists := s.sortOptions[query.Sort]
|
||||
if !exists {
|
||||
sortOpt = sortAlphaAsc
|
||||
}
|
||||
|
||||
search2 := false
|
||||
if s.Cfg != nil {
|
||||
search2 = s.Cfg.FeatureToggles["search2"]
|
||||
}
|
||||
|
||||
dashboardQuery := FindPersistedDashboardsQuery{
|
||||
Title: query.Title,
|
||||
SignedInUser: query.SignedInUser,
|
||||
IsStarred: query.IsStarred,
|
||||
DashboardIds: query.DashboardIds,
|
||||
Type: query.Type,
|
||||
FolderIds: query.FolderIds,
|
||||
Tags: query.Tags,
|
||||
Limit: query.Limit,
|
||||
Page: query.Page,
|
||||
Permission: query.Permission,
|
||||
Title: query.Title,
|
||||
SignedInUser: query.SignedInUser,
|
||||
IsStarred: query.IsStarred,
|
||||
DashboardIds: query.DashboardIds,
|
||||
Type: query.Type,
|
||||
FolderIds: query.FolderIds,
|
||||
Tags: query.Tags,
|
||||
Limit: query.Limit,
|
||||
Page: query.Page,
|
||||
Permission: query.Permission,
|
||||
FeatureSearch2: search2,
|
||||
SortBy: sortOpt.Filter,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&dashboardQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hits := sortedHits(dashboardQuery.Result)
|
||||
hits := dashboardQuery.Result
|
||||
if query.Sort == "" {
|
||||
hits = sortedHits(hits)
|
||||
}
|
||||
|
||||
if err := setStarredDashboards(query.SignedInUser.UserId, hits); err != nil {
|
||||
return err
|
||||
|
45
pkg/services/search/sorting.go
Normal file
45
pkg/services/search/sorting.go
Normal file
@ -0,0 +1,45 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"sort"
|
||||
)
|
||||
|
||||
var (
|
||||
sortAlphaAsc = SortOption{
|
||||
Name: "alpha-asc",
|
||||
DisplayName: "A-Z",
|
||||
Description: "Sort results in an alphabetically ascending order",
|
||||
Filter: searchstore.TitleSorter{},
|
||||
}
|
||||
sortAlphaDesc = SortOption{
|
||||
Name: "alpha-desc",
|
||||
DisplayName: "Z-A",
|
||||
Description: "Sort results in an alphabetically descending order",
|
||||
Filter: searchstore.TitleSorter{Descending: true},
|
||||
}
|
||||
)
|
||||
|
||||
type SortOption struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Description string
|
||||
Filter searchstore.FilterOrderBy
|
||||
}
|
||||
|
||||
// RegisterSortOption allows for hooking in more search options from
|
||||
// other services.
|
||||
func (s *SearchService) RegisterSortOption(option SortOption) {
|
||||
s.sortOptions[option.Name] = option
|
||||
}
|
||||
|
||||
func (s *SearchService) SortOptions() []SortOption {
|
||||
opts := make([]SortOption, 0, len(s.sortOptions))
|
||||
for _, o := range s.sortOptions {
|
||||
opts = append(opts, o)
|
||||
}
|
||||
sort.Slice(opts, func(i, j int) bool {
|
||||
return opts[i].Name < opts[j].Name
|
||||
})
|
||||
return opts
|
||||
}
|
@ -1,6 +1,11 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/permissions"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -11,6 +16,14 @@ import (
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
var shadowSearchCounter = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Subsystem: "db_dashboard",
|
||||
Name: "search_shadow",
|
||||
},
|
||||
[]string{"equal", "error"},
|
||||
)
|
||||
|
||||
func init() {
|
||||
bus.AddHandler("sql", SaveDashboard)
|
||||
bus.AddHandler("sql", GetDashboard)
|
||||
@ -26,6 +39,8 @@ func init() {
|
||||
bus.AddHandler("sql", ValidateDashboardBeforeSave)
|
||||
bus.AddHandler("sql", HasEditPermissionInFolders)
|
||||
bus.AddHandler("sql", HasAdminPermissionInFolders)
|
||||
|
||||
prometheus.MustRegister(shadowSearchCounter)
|
||||
}
|
||||
|
||||
var generateNewUid func() string = util.GenerateShortUID
|
||||
@ -206,20 +221,43 @@ func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSear
|
||||
WithTags(query.Tags).
|
||||
WithDashboardIdsIn(query.DashboardIds)
|
||||
|
||||
sb2filters := []interface{}{
|
||||
query.SortBy,
|
||||
permissions.DashboardPermissionFilter{
|
||||
OrgRole: query.SignedInUser.OrgRole,
|
||||
OrgId: query.SignedInUser.OrgId,
|
||||
Dialect: dialect,
|
||||
UserId: query.SignedInUser.UserId,
|
||||
PermissionLevel: query.Permission,
|
||||
},
|
||||
}
|
||||
|
||||
if len(query.Tags) > 0 {
|
||||
sb2filters = append(sb2filters, searchstore.TagsFilter{Tags: query.Tags})
|
||||
}
|
||||
|
||||
if len(query.DashboardIds) > 0 {
|
||||
sb2filters = append(sb2filters, searchstore.DashboardFilter{IDs: query.DashboardIds})
|
||||
}
|
||||
|
||||
if query.IsStarred {
|
||||
sb.IsStarred()
|
||||
sb2filters = append(sb2filters, searchstore.StarredFilter{UserId: query.SignedInUser.UserId})
|
||||
}
|
||||
|
||||
if len(query.Title) > 0 {
|
||||
sb.WithTitle(query.Title)
|
||||
sb2filters = append(sb2filters, searchstore.TitleFilter{Title: query.Title})
|
||||
}
|
||||
|
||||
if len(query.Type) > 0 {
|
||||
sb.WithType(query.Type)
|
||||
sb2filters = append(sb2filters, searchstore.TypeFilter{Dialect: dialect, Type: query.Type})
|
||||
}
|
||||
|
||||
if len(query.FolderIds) > 0 {
|
||||
sb.WithFolderIds(query.FolderIds)
|
||||
sb2filters = append(sb2filters, searchstore.FolderFilter{IDs: query.FolderIds})
|
||||
}
|
||||
|
||||
var res []DashboardSearchProjection
|
||||
@ -230,6 +268,36 @@ func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSear
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if query.FeatureSearch2 {
|
||||
var res2 []DashboardSearchProjection
|
||||
sb := &searchstore.Builder{Dialect: dialect, Filters: sb2filters}
|
||||
limit := query.Limit
|
||||
if limit < 1 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
page := query.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
shadowSql, params := sb.ToSql(limit, page)
|
||||
err = x.SQL(shadowSql, params...).Find(&res2)
|
||||
|
||||
equal := reflect.DeepEqual(res2, res)
|
||||
shadowSearchCounter.With(prometheus.Labels{
|
||||
"equal": strconv.FormatBool(equal),
|
||||
"error": strconv.FormatBool(err != nil),
|
||||
}).Inc()
|
||||
sqlog.Debug(
|
||||
"shadow search query result",
|
||||
"err", err,
|
||||
"equal", equal,
|
||||
"shadowQuery", strings.Replace(strings.Replace(shadowSql, "\n", " ", -1), "\t", " ", -1),
|
||||
"query", strings.Replace(strings.Replace(sql, "\n", " ", -1), "\t", " ", -1),
|
||||
)
|
||||
return res2, nil
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
|
76
pkg/services/sqlstore/permissions/dashboard.go
Normal file
76
pkg/services/sqlstore/permissions/dashboard.go
Normal file
@ -0,0 +1,76 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DashboardPermissionFilter struct {
|
||||
OrgRole models.RoleType
|
||||
Dialect migrator.Dialect
|
||||
UserId int64
|
||||
OrgId int64
|
||||
PermissionLevel models.PermissionType
|
||||
}
|
||||
|
||||
func (d DashboardPermissionFilter) Where() (string, []interface{}) {
|
||||
if d.OrgRole == models.ROLE_ADMIN {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
okRoles := []interface{}{d.OrgRole}
|
||||
if d.OrgRole == models.ROLE_EDITOR {
|
||||
okRoles = append(okRoles, models.ROLE_VIEWER)
|
||||
}
|
||||
|
||||
falseStr := d.Dialect.BooleanStr(false)
|
||||
|
||||
sql := `(
|
||||
dashboard.id IN (
|
||||
SELECT distinct DashboardId from (
|
||||
SELECT d.id AS DashboardId
|
||||
FROM dashboard AS d
|
||||
LEFT JOIN dashboard AS folder on folder.id = d.folder_id
|
||||
LEFT JOIN dashboard_acl AS da ON
|
||||
da.dashboard_id = d.id OR
|
||||
da.dashboard_id = d.folder_id
|
||||
LEFT JOIN team_member as ugm on ugm.team_id = da.team_id
|
||||
WHERE
|
||||
d.org_id = ? AND
|
||||
da.permission >= ? AND
|
||||
(
|
||||
da.user_id = ? OR
|
||||
ugm.user_id = ? OR
|
||||
da.role IN (?` + strings.Repeat(",?", len(okRoles)-1) + `)
|
||||
)
|
||||
UNION
|
||||
SELECT d.id AS DashboardId
|
||||
FROM dashboard AS d
|
||||
LEFT JOIN dashboard AS folder on folder.id = d.folder_id
|
||||
LEFT JOIN dashboard_acl AS da ON
|
||||
(
|
||||
-- include default permissions -->
|
||||
da.org_id = -1 AND (
|
||||
(folder.id IS NOT NULL AND folder.has_acl = ` + falseStr + `) OR
|
||||
(folder.id IS NULL AND d.has_acl = ` + falseStr + `)
|
||||
)
|
||||
)
|
||||
WHERE
|
||||
d.org_id = ? AND
|
||||
da.permission >= ? AND
|
||||
(
|
||||
da.user_id = ? OR
|
||||
da.role IN (?` + strings.Repeat(",?", len(okRoles)-1) + `)
|
||||
)
|
||||
) AS a
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
params := []interface{}{d.OrgId, d.PermissionLevel, d.UserId, d.UserId}
|
||||
params = append(params, okRoles...)
|
||||
params = append(params, d.OrgId, d.PermissionLevel, d.UserId)
|
||||
params = append(params, okRoles...)
|
||||
return sql, params
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
@ -9,6 +10,8 @@ import (
|
||||
// SearchBuilder is a builder/object mother that builds a dashboard search query
|
||||
type SearchBuilder struct {
|
||||
SqlBuilder
|
||||
|
||||
dialect migrator.Dialect
|
||||
tags []string
|
||||
isStarred bool
|
||||
limit int64
|
||||
@ -38,11 +41,17 @@ func NewSearchBuilder(signedInUser *models.SignedInUser, limit int64, page int64
|
||||
limit: limit,
|
||||
page: page,
|
||||
permission: permission,
|
||||
dialect: dialect,
|
||||
}
|
||||
|
||||
return searchBuilder
|
||||
}
|
||||
|
||||
func (sb *SearchBuilder) WithDialect(dialect migrator.Dialect) *SearchBuilder {
|
||||
sb.dialect = dialect
|
||||
return sb
|
||||
}
|
||||
|
||||
func (sb *SearchBuilder) WithTags(tags []string) *SearchBuilder {
|
||||
if len(tags) > 0 {
|
||||
sb.tags = tags
|
||||
@ -101,7 +110,7 @@ func (sb *SearchBuilder) ToSql() (string, []interface{}) {
|
||||
}
|
||||
|
||||
sb.sql.WriteString(`
|
||||
ORDER BY dashboard.id ` + dialect.LimitOffset(sb.limit, (sb.page-1)*sb.limit) + `) as ids
|
||||
ORDER BY dashboard.id ` + sb.dialect.LimitOffset(sb.limit, (sb.page-1)*sb.limit) + `) as ids
|
||||
INNER JOIN dashboard on ids.id = dashboard.id
|
||||
`)
|
||||
|
||||
@ -184,16 +193,16 @@ func (sb *SearchBuilder) buildSearchWhereClause() {
|
||||
sb.writeDashboardPermissionFilter(sb.signedInUser, sb.permission)
|
||||
|
||||
if len(sb.whereTitle) > 0 {
|
||||
sb.sql.WriteString(" AND dashboard.title " + dialect.LikeStr() + " ?")
|
||||
sb.sql.WriteString(" AND dashboard.title " + sb.dialect.LikeStr() + " ?")
|
||||
sb.params = append(sb.params, "%"+sb.whereTitle+"%")
|
||||
}
|
||||
|
||||
if sb.whereTypeFolder {
|
||||
sb.sql.WriteString(" AND dashboard.is_folder = " + dialect.BooleanStr(true))
|
||||
sb.sql.WriteString(" AND dashboard.is_folder = " + sb.dialect.BooleanStr(true))
|
||||
}
|
||||
|
||||
if sb.whereTypeDash {
|
||||
sb.sql.WriteString(" AND dashboard.is_folder = " + dialect.BooleanStr(false))
|
||||
sb.sql.WriteString(" AND dashboard.is_folder = " + sb.dialect.BooleanStr(false))
|
||||
}
|
||||
|
||||
if len(sb.whereFolderIds) > 0 {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
@ -9,6 +10,9 @@ import (
|
||||
|
||||
func TestSearchBuilder(t *testing.T) {
|
||||
Convey("Testing building a search", t, func() {
|
||||
if dialect == nil {
|
||||
dialect = &migrator.Sqlite3{}
|
||||
}
|
||||
signedInUser := &models.SignedInUser{
|
||||
OrgId: 1,
|
||||
UserId: 1,
|
||||
|
112
pkg/services/sqlstore/searchstore/builder.go
Normal file
112
pkg/services/sqlstore/searchstore/builder.go
Normal file
@ -0,0 +1,112 @@
|
||||
package searchstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Builder defaults to returning a SQL query to get a list of all dashboards
|
||||
// in default order, but can be modified by applying filters.
|
||||
type Builder struct {
|
||||
// List of FilterWhere/FilterGroupBy/FilterOrderBy/FilterLeftJoin
|
||||
// to modify the query.
|
||||
Filters []interface{}
|
||||
Dialect migrator.Dialect
|
||||
|
||||
params []interface{}
|
||||
sql bytes.Buffer
|
||||
}
|
||||
|
||||
// ToSql builds the SQL query and returns it as a string, together with the SQL parameters.
|
||||
func (b *Builder) ToSql(limit, page int64) (string, []interface{}) {
|
||||
b.params = make([]interface{}, 0)
|
||||
b.sql = bytes.Buffer{}
|
||||
|
||||
b.buildSelect()
|
||||
|
||||
b.sql.WriteString("( ")
|
||||
b.applyFilters()
|
||||
|
||||
b.sql.WriteString(b.Dialect.LimitOffset(limit, (page-1)*limit) + `) AS ids
|
||||
INNER JOIN dashboard ON ids.id = dashboard.id
|
||||
`)
|
||||
|
||||
b.sql.WriteString(`
|
||||
LEFT OUTER JOIN dashboard AS folder ON folder.id = dashboard.folder_id
|
||||
LEFT OUTER JOIN dashboard_tag ON dashboard.id = dashboard_tag.dashboard_id`)
|
||||
|
||||
return b.sql.String(), b.params
|
||||
}
|
||||
|
||||
func (b *Builder) buildSelect() {
|
||||
b.sql.WriteString(
|
||||
`SELECT
|
||||
dashboard.id,
|
||||
dashboard.uid,
|
||||
dashboard.title,
|
||||
dashboard.slug,
|
||||
dashboard_tag.term,
|
||||
dashboard.is_folder,
|
||||
dashboard.folder_id,
|
||||
folder.uid AS folder_uid,
|
||||
folder.slug AS folder_slug,
|
||||
folder.title AS folder_title
|
||||
FROM `)
|
||||
}
|
||||
|
||||
func (b *Builder) applyFilters() {
|
||||
joins := []string{}
|
||||
|
||||
wheres := []string{}
|
||||
whereParams := []interface{}{}
|
||||
|
||||
groups := []string{}
|
||||
groupParams := []interface{}{}
|
||||
|
||||
orders := []string{}
|
||||
|
||||
for _, f := range b.Filters {
|
||||
if f, ok := f.(FilterLeftJoin); ok {
|
||||
joins = append(joins, fmt.Sprintf(" LEFT OUTER JOIN %s ", f.LeftJoin()))
|
||||
}
|
||||
|
||||
if f, ok := f.(FilterWhere); ok {
|
||||
sql, params := f.Where()
|
||||
if sql != "" {
|
||||
wheres = append(wheres, sql)
|
||||
whereParams = append(whereParams, params...)
|
||||
}
|
||||
}
|
||||
|
||||
if f, ok := f.(FilterGroupBy); ok {
|
||||
sql, params := f.GroupBy()
|
||||
if sql != "" {
|
||||
groups = append(groups, sql)
|
||||
groupParams = append(groupParams, params...)
|
||||
}
|
||||
}
|
||||
|
||||
if f, ok := f.(FilterOrderBy); ok {
|
||||
orders = append(orders, f.OrderBy())
|
||||
}
|
||||
}
|
||||
|
||||
b.sql.WriteString("SELECT dashboard.id FROM dashboard")
|
||||
b.sql.WriteString(strings.Join(joins, ""))
|
||||
|
||||
if len(wheres) > 0 {
|
||||
b.sql.WriteString(fmt.Sprintf(" WHERE %s", strings.Join(wheres, " AND ")))
|
||||
b.params = append(b.params, whereParams...)
|
||||
}
|
||||
|
||||
if len(groups) > 0 {
|
||||
b.sql.WriteString(fmt.Sprintf(" GROUP BY %s", strings.Join(groups, ", ")))
|
||||
b.params = append(b.params, groupParams...)
|
||||
}
|
||||
|
||||
if len(orders) > 0 {
|
||||
b.sql.WriteString(fmt.Sprintf(" ORDER BY %s", strings.Join(orders, ", ")))
|
||||
}
|
||||
}
|
35
pkg/services/sqlstore/searchstore/doc.go
Normal file
35
pkg/services/sqlstore/searchstore/doc.go
Normal file
@ -0,0 +1,35 @@
|
||||
// Package searchstore converts search queries to SQL.
|
||||
//
|
||||
// Because of the wide array of deployments supported by Grafana,
|
||||
// search strives to be both performant enough to handle heavy users
|
||||
// and lightweight enough to not increase complexity/resource
|
||||
// utilization for light users. To allow this we're currently searching
|
||||
// without fuzziness and in a single SQL query.
|
||||
//
|
||||
// Search queries are a combination of an outer query which Builder
|
||||
// creates automatically when calling the Builder.ToSql method and an
|
||||
// inner query feeding that which lists the IDs of the dashboards that
|
||||
// should be part of the result set. By default search will return all
|
||||
// dashboards (behind pagination) but it is possible to dynamically add
|
||||
// filters capable of adding more specific inclusion or ordering
|
||||
// requirements.
|
||||
//
|
||||
// A filter is any data type which implements one or more of the
|
||||
// FilterWhere, FilterGroupBy, FilterOrderBy, or FilterLeftJoin
|
||||
// interfaces. The filters will be applied (in order) to limit or
|
||||
// reorder the results.
|
||||
//
|
||||
// Filters will be applied in order with the final result like such:
|
||||
//
|
||||
// SELECT id FROM dashboard LEFT OUTER JOIN <FilterLeftJoin...>
|
||||
// WHERE <FilterWhere[0]> AND ... AND <FilterWhere[n]>
|
||||
// GROUP BY <FilterGroupBy...>
|
||||
// ORDER BY <FilterOrderBy...>
|
||||
// LIMIT <limit> OFFSET <(page-1)*limit>;
|
||||
//
|
||||
// This structure is intended to isolate the filters from each other
|
||||
// and implementors are expected to add all the required joins, where
|
||||
// clauses, groupings, and/or orderings necessary for applying a
|
||||
// filter in the filter. Using side-effects of other filters is
|
||||
// bad manners and increases the complexity and volatility of the code.
|
||||
package searchstore
|
145
pkg/services/sqlstore/searchstore/filters.go
Normal file
145
pkg/services/sqlstore/searchstore/filters.go
Normal file
@ -0,0 +1,145 @@
|
||||
package searchstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FilterWhere limits the set of dashboard IDs to the dashboards for
|
||||
// which the filter is applicable. Results where the first value is
|
||||
// an empty string are discarded.
|
||||
type FilterWhere interface {
|
||||
Where() (string, []interface{})
|
||||
}
|
||||
|
||||
// FilterGroupBy should be used after performing an outer join on the
|
||||
// search result to ensure there is only one of each ID in the results.
|
||||
// The id column must be present in the result.
|
||||
type FilterGroupBy interface {
|
||||
GroupBy() (string, []interface{})
|
||||
}
|
||||
|
||||
// FilterOrderBy provides an ordering for the search result.
|
||||
type FilterOrderBy interface {
|
||||
OrderBy() string
|
||||
}
|
||||
|
||||
// FilterLeftJoin adds the returned string as a "LEFT OUTER JOIN" to
|
||||
// allow for fetching extra columns from a table outside of the
|
||||
// dashboard column.
|
||||
type FilterLeftJoin interface {
|
||||
LeftJoin() string
|
||||
}
|
||||
|
||||
const (
|
||||
TypeFolder = "dash-folder"
|
||||
TypeDashboard = "dash-db"
|
||||
)
|
||||
|
||||
type TypeFilter struct {
|
||||
Dialect migrator.Dialect
|
||||
Type string
|
||||
}
|
||||
|
||||
func (f TypeFilter) Where() (string, []interface{}) {
|
||||
if f.Type == TypeFolder {
|
||||
return "dashboard.is_folder = " + f.Dialect.BooleanStr(true), nil
|
||||
}
|
||||
|
||||
if f.Type == TypeDashboard {
|
||||
return "dashboard.is_folder = " + f.Dialect.BooleanStr(false), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type OrgFilter struct {
|
||||
OrgId int64
|
||||
}
|
||||
|
||||
func (f OrgFilter) Where() (string, []interface{}) {
|
||||
return "dashboard.org_id=?", []interface{}{f.OrgId}
|
||||
}
|
||||
|
||||
type StarredFilter struct {
|
||||
UserId int64
|
||||
}
|
||||
|
||||
func (f StarredFilter) Where() (string, []interface{}) {
|
||||
return `(SELECT count(*)
|
||||
FROM star
|
||||
WHERE star.dashboard_id = dashboard.id AND star.user_id = ?) > 0`, []interface{}{f.UserId}
|
||||
}
|
||||
|
||||
type TitleFilter struct {
|
||||
Dialect migrator.Dialect
|
||||
Title string
|
||||
}
|
||||
|
||||
func (f TitleFilter) Where() (string, []interface{}) {
|
||||
return fmt.Sprintf("dashboard.title %s ?", f.Dialect.LikeStr()), []interface{}{"%" + f.Title + "%"}
|
||||
}
|
||||
|
||||
type FolderFilter struct {
|
||||
IDs []int64
|
||||
}
|
||||
|
||||
func (f FolderFilter) Where() (string, []interface{}) {
|
||||
return sqlIDin("dashboard.folder_id", f.IDs)
|
||||
}
|
||||
|
||||
type DashboardFilter struct {
|
||||
IDs []int64
|
||||
}
|
||||
|
||||
func (f DashboardFilter) Where() (string, []interface{}) {
|
||||
return sqlIDin("dashboard.id", f.IDs)
|
||||
}
|
||||
|
||||
type TagsFilter struct {
|
||||
Tags []string
|
||||
}
|
||||
|
||||
func (f TagsFilter) LeftJoin() string {
|
||||
return `dashboard_tag ON dashboard_tag.dashboard_id = dashboard.id`
|
||||
}
|
||||
|
||||
func (f TagsFilter) GroupBy() (string, []interface{}) {
|
||||
return `dashboard.id HAVING COUNT(dashboard.id) >= ?`, []interface{}{len(f.Tags)}
|
||||
}
|
||||
|
||||
func (f TagsFilter) Where() (string, []interface{}) {
|
||||
params := make([]interface{}, len(f.Tags))
|
||||
for i, tag := range f.Tags {
|
||||
params[i] = tag
|
||||
}
|
||||
return `dashboard_tag.term IN (?` + strings.Repeat(",?", len(f.Tags)-1) + `)`, params
|
||||
}
|
||||
|
||||
type TitleSorter struct {
|
||||
Descending bool
|
||||
}
|
||||
|
||||
func (s TitleSorter) OrderBy() string {
|
||||
if s.Descending {
|
||||
return "dashboard.title DESC"
|
||||
}
|
||||
|
||||
return "dashboard.title ASC"
|
||||
}
|
||||
|
||||
func sqlIDin(column string, ids []int64) (string, []interface{}) {
|
||||
length := len(ids)
|
||||
if length < 1 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
sqlArray := "(?" + strings.Repeat(",?", length-1) + ")"
|
||||
|
||||
params := []interface{}{}
|
||||
for _, id := range ids {
|
||||
params = append(params, id)
|
||||
}
|
||||
return fmt.Sprintf("%s IN %s", column, sqlArray), params
|
||||
}
|
215
pkg/services/sqlstore/searchstore/search_test.go
Normal file
215
pkg/services/sqlstore/searchstore/search_test.go
Normal file
@ -0,0 +1,215 @@
|
||||
// package search_test contains integration tests for search
|
||||
package searchstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/permissions"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var dialect migrator.Dialect
|
||||
|
||||
const (
|
||||
limit int64 = 15
|
||||
page int64 = 1
|
||||
)
|
||||
|
||||
func TestBuilder_EqualResults_Basic(t *testing.T) {
|
||||
user := &models.SignedInUser{
|
||||
UserId: 1,
|
||||
OrgId: 1,
|
||||
OrgRole: models.ROLE_EDITOR,
|
||||
}
|
||||
|
||||
db := setupTestEnvironment(t)
|
||||
err := createDashboards(0, 1, user.OrgId)
|
||||
require.NoError(t, err)
|
||||
|
||||
// create one dashboard in another organization that shouldn't
|
||||
// be listed in the results.
|
||||
err = createDashboards(1, 2, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := &searchstore.Builder{
|
||||
Filters: []interface{}{
|
||||
searchstore.OrgFilter{OrgId: user.OrgId},
|
||||
searchstore.TitleSorter{},
|
||||
},
|
||||
Dialect: dialect,
|
||||
}
|
||||
|
||||
prevBuilder := sqlstore.NewSearchBuilder(user, limit, page, models.PERMISSION_EDIT)
|
||||
prevBuilder.WithDialect(dialect)
|
||||
|
||||
newRes := []sqlstore.DashboardSearchProjection{}
|
||||
err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
sql, params := builder.ToSql(limit, page)
|
||||
return sess.SQL(sql, params...).Find(&newRes)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
oldRes := []sqlstore.DashboardSearchProjection{}
|
||||
err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
sql, params := prevBuilder.ToSql()
|
||||
return sess.SQL(sql, params...).Find(&oldRes)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, newRes, 1)
|
||||
assert.EqualValues(t, oldRes, newRes)
|
||||
}
|
||||
|
||||
func TestBuilder_Pagination(t *testing.T) {
|
||||
user := &models.SignedInUser{
|
||||
UserId: 1,
|
||||
OrgId: 1,
|
||||
OrgRole: models.ROLE_VIEWER,
|
||||
}
|
||||
|
||||
db := setupTestEnvironment(t)
|
||||
err := createDashboards(0, 25, user.OrgId)
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := &searchstore.Builder{
|
||||
Filters: []interface{}{
|
||||
searchstore.OrgFilter{OrgId: user.OrgId},
|
||||
searchstore.TitleSorter{},
|
||||
},
|
||||
Dialect: dialect,
|
||||
}
|
||||
|
||||
resPg1 := []sqlstore.DashboardSearchProjection{}
|
||||
resPg2 := []sqlstore.DashboardSearchProjection{}
|
||||
resPg3 := []sqlstore.DashboardSearchProjection{}
|
||||
err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
sql, params := builder.ToSql(15, 1)
|
||||
err := sess.SQL(sql, params...).Find(&resPg1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sql, params = builder.ToSql(15, 2)
|
||||
err = sess.SQL(sql, params...).Find(&resPg2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sql, params = builder.ToSql(15, 3)
|
||||
return sess.SQL(sql, params...).Find(&resPg3)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, resPg1, 15)
|
||||
assert.Len(t, resPg2, 10)
|
||||
assert.Len(t, resPg3, 0, "sanity check: pages after last should be empty")
|
||||
|
||||
assert.Equal(t, "A", resPg1[0].Title, "page 1 should start with the first dashboard")
|
||||
assert.Equal(t, "P", resPg2[0].Title, "page 2 should start with the 16th dashboard")
|
||||
}
|
||||
|
||||
func TestBuilder_Permissions(t *testing.T) {
|
||||
user := &models.SignedInUser{
|
||||
UserId: 1,
|
||||
OrgId: 1,
|
||||
OrgRole: models.ROLE_VIEWER,
|
||||
}
|
||||
|
||||
db := setupTestEnvironment(t)
|
||||
err := createDashboards(0, 1, user.OrgId)
|
||||
require.NoError(t, err)
|
||||
|
||||
level := models.PERMISSION_EDIT
|
||||
|
||||
builder := &searchstore.Builder{
|
||||
Filters: []interface{}{
|
||||
searchstore.OrgFilter{OrgId: user.OrgId},
|
||||
searchstore.TitleSorter{},
|
||||
permissions.DashboardPermissionFilter{
|
||||
Dialect: dialect,
|
||||
OrgRole: user.OrgRole,
|
||||
OrgId: user.OrgId,
|
||||
UserId: user.UserId,
|
||||
PermissionLevel: level,
|
||||
},
|
||||
},
|
||||
Dialect: dialect,
|
||||
}
|
||||
|
||||
prevBuilder := sqlstore.NewSearchBuilder(user, limit, page, level)
|
||||
prevBuilder.WithDialect(dialect)
|
||||
|
||||
newRes := []sqlstore.DashboardSearchProjection{}
|
||||
err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
sql, params := builder.ToSql(limit, page)
|
||||
return sess.SQL(sql, params...).Find(&newRes)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
oldRes := []sqlstore.DashboardSearchProjection{}
|
||||
err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
sql, params := prevBuilder.ToSql()
|
||||
return sess.SQL(sql, params...).Find(&oldRes)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, newRes, 0)
|
||||
assert.EqualValues(t, oldRes, newRes)
|
||||
}
|
||||
|
||||
func setupTestEnvironment(t *testing.T) *sqlstore.SqlStore {
|
||||
t.Helper()
|
||||
store := sqlstore.InitTestDB(t)
|
||||
dialect = store.Dialect
|
||||
return store
|
||||
}
|
||||
|
||||
func createDashboards(startID, endID int, orgID int64) error {
|
||||
if endID < startID {
|
||||
return fmt.Errorf("startID must be smaller than endID")
|
||||
}
|
||||
|
||||
for i := startID; i < endID; i++ {
|
||||
dashboard, err := simplejson.NewJson([]byte(`{
|
||||
"id": null,
|
||||
"uid": null,
|
||||
"title": "` + lexiCounter(i) + `",
|
||||
"tags": [ "templated" ],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 16,
|
||||
"version": 0
|
||||
}`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = sqlstore.SaveDashboard(&models.SaveDashboardCommand{
|
||||
Dashboard: dashboard,
|
||||
UserId: 1,
|
||||
OrgId: orgID,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lexiCounter counts in a lexicographically sortable order.
|
||||
func lexiCounter(n int) string {
|
||||
alphabet := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
value := string(alphabet[n%26])
|
||||
|
||||
if n >= 26 {
|
||||
value = lexiCounter(n/26-1) + value
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
Loading…
Reference in New Issue
Block a user