diff --git a/api/restHandler/app/appList/AppListingRestHandler.go b/api/restHandler/app/appList/AppListingRestHandler.go index 8c15bee342..a2a800a170 100644 --- a/api/restHandler/app/appList/AppListingRestHandler.go +++ b/api/restHandler/app/appList/AppListingRestHandler.go @@ -56,6 +56,7 @@ import ( "github.com/gorilla/mux" "go.opentelemetry.io/otel" "go.uber.org/zap" + "gopkg.in/go-playground/validator.v9" "net/http" "strconv" "time" @@ -98,6 +99,7 @@ type AppListingRestHandlerImpl struct { k8sApplicationService k8sApplication.K8sApplicationService deploymentConfigService common2.DeploymentConfigService resourceTreeService resourceTree.Service + validator *validator.Validate } type AppStatus struct { @@ -141,6 +143,7 @@ func NewAppListingRestHandlerImpl(appListingService app.AppListingService, k8sApplicationService: k8sApplicationService, deploymentConfigService: deploymentConfigService, resourceTreeService: resourceTreeService, + validator: validator.New(), } return appListingHandler } @@ -276,6 +279,25 @@ func (handler AppListingRestHandlerImpl) FetchJobOverviewCiPipelines(w http.Resp common.WriteJsonResp(w, err, jobCi, http.StatusOK) } +// validateAndNormalizeFetchAppListingRequest applies request-level validation first, +// then tag-filter business validation, and finally normalization. +func (handler AppListingRestHandlerImpl) validateAndNormalizeFetchAppListingRequest(w http.ResponseWriter, r *http.Request, fetchAppListingRequest *app.FetchAppListingRequest) bool { + err := handler.validator.Struct(*fetchAppListingRequest) + if err != nil { + handler.logger.Errorw("validation err, FetchAppsByEnvironment", "err", err, "payload", fetchAppListingRequest) + common.HandleValidationErrors(w, r, err) + return false + } + err = handler.appListingService.ValidateTagFilters(fetchAppListingRequest.TagFilters) + if err != nil { + handler.logger.Errorw("request err, ValidateTagFilters", "err", err, "payload", fetchAppListingRequest.TagFilters) + common.WriteJsonResp(w, err, nil, http.StatusBadRequest) + return false + } + fetchAppListingRequest.TagFilters = handler.appListingService.NormalizeTagFilters(fetchAppListingRequest.TagFilters) + return true +} + func (handler AppListingRestHandlerImpl) FetchAppsByEnvironmentV2(w http.ResponseWriter, r *http.Request) { //Allow CORS here By * or specific origin util3.SetupCorsOriginHeader(&w) @@ -331,6 +353,9 @@ func (handler AppListingRestHandlerImpl) FetchAppsByEnvironmentV2(w http.Respons common.WriteJsonResp(w, err, nil, http.StatusBadRequest) return } + if !handler.validateAndNormalizeFetchAppListingRequest(w, r, &fetchAppListingRequest) { + return + } newCtx, span = otel.Tracer("fetchAppListingRequest").Start(newCtx, "GetNamespaceClusterMapping") _, _, err = fetchAppListingRequest.GetNamespaceClusterMapping() span.End() diff --git a/internal/sql/repository/helper/AppListingRepositoryQueryBuilder.go b/internal/sql/repository/helper/AppListingRepositoryQueryBuilder.go index b6b879a6ad..e0c7704825 100644 --- a/internal/sql/repository/helper/AppListingRepositoryQueryBuilder.go +++ b/internal/sql/repository/helper/AppListingRepositoryQueryBuilder.go @@ -21,6 +21,7 @@ import ( "github.com/devtron-labs/devtron/util" "github.com/go-pg/pg" "go.uber.org/zap" + "strings" ) type AppType int @@ -44,32 +45,69 @@ func NewAppListingRepositoryQueryBuilder(logger *zap.SugaredLogger) AppListingRe } type AppListingFilter struct { - Environments []int `json:"environments"` - Statuses []string `json:"statutes"` - Teams []int `json:"teams"` - AppStatuses []string `json:"appStatuses"` - AppNameSearch string `json:"appNameSearch"` - SortOrder SortOrder `json:"sortOrder"` - SortBy SortBy `json:"sortBy"` - Offset int `json:"offset"` - Size int `json:"size"` - DeploymentGroupId int `json:"deploymentGroupId"` - AppIds []int `json:"-"` // internal use only + Environments []int `json:"environments"` + Statuses []string `json:"statutes"` + Teams []int `json:"teams"` + AppStatuses []string `json:"appStatuses"` + TagFilters []TagFilter `json:"tagFilters"` + AppNameSearch string `json:"appNameSearch"` + SortOrder SortOrder `json:"sortOrder"` + SortBy SortBy `json:"sortBy"` + Offset int `json:"offset"` + Size int `json:"size"` + DeploymentGroupId int `json:"deploymentGroupId"` + AppIds []int `json:"-"` // internal use only } type SortBy string type SortOrder string +type TagFilterOperator string + +// TagFilter holds one row of label filter sent by UI. +// key is always required. +// value is required for EQUALS/DOES_NOT_EQUAL/CONTAINS/DOES_NOT_CONTAIN. +// value must be absent for EXISTS/DOES_NOT_EXIST. +type TagFilter struct { + Key string `json:"key" validate:"required"` + Operator TagFilterOperator `json:"operator" validate:"required"` + Value *string `json:"value"` +} const ( Asc SortOrder = "ASC" Desc SortOrder = "DESC" ) +const ( + TagFilterOperatorEquals TagFilterOperator = "EQUALS" + TagFilterOperatorDoesNotEqual TagFilterOperator = "DOES_NOT_EQUAL" + TagFilterOperatorContains TagFilterOperator = "CONTAINS" + TagFilterOperatorDoesNotContain TagFilterOperator = "DOES_NOT_CONTAIN" + TagFilterOperatorExists TagFilterOperator = "EXISTS" + TagFilterOperatorDoesNotExist TagFilterOperator = "DOES_NOT_EXIST" +) + const ( AppNameSortBy SortBy = "appNameSort" LastDeployedSortBy = "lastDeployedSort" ) +var likePatternEscaper = strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_") + +func (operator TagFilterOperator) IsValid() bool { + switch operator { + case TagFilterOperatorEquals, + TagFilterOperatorDoesNotEqual, + TagFilterOperatorContains, + TagFilterOperatorDoesNotContain, + TagFilterOperatorExists, + TagFilterOperatorDoesNotExist: + return true + default: + return false + } +} + func (impl AppListingRepositoryQueryBuilder) BuildJobListingQuery(appIDs []int, statuses []string, environmentIds []int, sortOrder string) (string, []interface{}) { var queryParams []interface{} query := `select ci_pipeline.name as ci_pipeline_name,ci_pipeline.id as ci_pipeline_id,app.id as job_id,app.display_name @@ -273,6 +311,13 @@ func (impl AppListingRepositoryQueryBuilder) buildAppListingWhereCondition(appLi whereCondition += " and aps.status IN (?) " queryParams = append(queryParams, pg.In(appStatusExcludingNotDeployed)) } + + // Tag filters are AND-combined for now as requested by product. + // Each row translates to a correlated EXISTS/NOT EXISTS on app_label. + tagWhereCondition, tagQueryParams := impl.buildTagFiltersWhereConditionAND(appListingFilter.TagFilters) + whereCondition += tagWhereCondition + queryParams = append(queryParams, tagQueryParams...) + if len(appListingFilter.AppIds) > 0 { whereCondition += " and a.id IN (?) " queryParams = append(queryParams, pg.In(appListingFilter.AppIds)) @@ -280,6 +325,70 @@ func (impl AppListingRepositoryQueryBuilder) buildAppListingWhereCondition(appLi return whereCondition, queryParams } +func (impl AppListingRepositoryQueryBuilder) buildTagFiltersWhereConditionAND(tagFilters []TagFilter) (string, []interface{}) { + if len(tagFilters) == 0 { + return "", nil + } + var queryBuilder strings.Builder + queryParams := make([]interface{}, 0, len(tagFilters)*2) + for _, tagFilter := range tagFilters { + predicate, predicateParams := impl.buildTagFilterPredicate(tagFilter) + queryBuilder.WriteString(" and ") + queryBuilder.WriteString(predicate) + queryParams = append(queryParams, predicateParams...) + } + return queryBuilder.String(), queryParams +} + +// buildTagFilterPredicate converts one UI tag filter row into a SQL predicate. +// Operator behavior (all case-sensitive): +// - EQUALS: key exists with exact value match. +// - DOES_NOT_EQUAL: key exists with at least one value different from target. +// - CONTAINS: key exists with at least one value containing target substring. +// - DOES_NOT_CONTAIN: key exists with at least one value not containing target substring. +// - EXISTS: key exists. +// - DOES_NOT_EXIST: key does not exist. +func (impl AppListingRepositoryQueryBuilder) buildTagFilterPredicate(tagFilter TagFilter) (string, []interface{}) { + value := "" + if tagFilter.Value != nil { + value = *tagFilter.Value + } + switch tagFilter.Operator { + case TagFilterOperatorEquals: + return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value = ?)", + []interface{}{tagFilter.Key, value} + case TagFilterOperatorDoesNotEqual: + // Best-practice semantics for multi-value keys: + // include app when key exists and at least one value is different from target. + return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value <> ?)", + []interface{}{tagFilter.Key, value} + case TagFilterOperatorContains: + return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value LIKE ? ESCAPE '\\')", + []interface{}{tagFilter.Key, buildContainsPattern(value)} + case TagFilterOperatorDoesNotContain: + // Best-practice semantics for multi-value keys: + // include app when key exists and at least one value does not contain target. + return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value NOT LIKE ? ESCAPE '\\')", + []interface{}{tagFilter.Key, buildContainsPattern(value)} + case TagFilterOperatorExists: + return "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ?)", + []interface{}{tagFilter.Key} + case TagFilterOperatorDoesNotExist: + return "NOT EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ?)", + []interface{}{tagFilter.Key} + default: + // Invalid operator should never reach here due request validation. + // Returning false condition keeps query safe if validation is bypassed. + return "1 = 0", nil + } +} + +func buildContainsPattern(value string) string { + // Escape SQL LIKE wildcard chars so "contains" behaves like plain substring search. + escaped := likePatternEscaper.Replace(value) + return "%" + escaped + "%" +} + func GetCommaSepratedString[T int | string](request []T) string { respString := "" for i, item := range request { diff --git a/internal/sql/repository/helper/AppListingRepositoryQueryBuilder_tag_filters_test.go b/internal/sql/repository/helper/AppListingRepositoryQueryBuilder_tag_filters_test.go new file mode 100644 index 0000000000..0d768da664 --- /dev/null +++ b/internal/sql/repository/helper/AppListingRepositoryQueryBuilder_tag_filters_test.go @@ -0,0 +1,66 @@ +package helper + +import ( + "go.uber.org/zap" + "testing" + + "github.com/stretchr/testify/assert" +) + +func stringPointer(value string) *string { + return &value +} + +func TestBuildAppListingWhereCondition_WithTagFiltersAnd(t *testing.T) { + queryBuilder := NewAppListingRepositoryQueryBuilder(zap.NewNop().Sugar()) + whereClause, queryParams := queryBuilder.buildAppListingWhereCondition(AppListingFilter{ + TagFilters: []TagFilter{ + {Key: "owner", Operator: TagFilterOperatorEquals, Value: stringPointer("James")}, + {Key: "env", Operator: TagFilterOperatorDoesNotContain, Value: stringPointer("pro_d%")}, + {Key: "team", Operator: TagFilterOperatorExists, Value: nil}, + {Key: "zone", Operator: TagFilterOperatorDoesNotExist, Value: nil}, + }, + }) + + assert.Contains(t, whereClause, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value = ?)") + assert.Contains(t, whereClause, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value NOT LIKE ? ESCAPE '\\')") + assert.Contains(t, whereClause, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ?)") + assert.Contains(t, whereClause, "NOT EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ?)") + assert.Len(t, queryParams, 8) + assert.Equal(t, true, queryParams[0]) + assert.Equal(t, CustomApp, queryParams[1]) + assert.Equal(t, "owner", queryParams[2]) + assert.Equal(t, "James", queryParams[3]) + assert.Equal(t, "env", queryParams[4]) + assert.Equal(t, "%pro\\_d\\%%", queryParams[5]) + assert.Equal(t, "team", queryParams[6]) + assert.Equal(t, "zone", queryParams[7]) +} + +func TestBuildTagFilterPredicate_DoesNotEqualRequiresKeyAndDifferentValue(t *testing.T) { + queryBuilder := NewAppListingRepositoryQueryBuilder(zap.NewNop().Sugar()) + value := "mayank" + + predicate, queryParams := queryBuilder.buildTagFilterPredicate(TagFilter{ + Key: "owner", + Operator: TagFilterOperatorDoesNotEqual, + Value: &value, + }) + + assert.Equal(t, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value <> ?)", predicate) + assert.Equal(t, []interface{}{"owner", "mayank"}, queryParams) +} + +func TestBuildTagFilterPredicate_DoesNotContainRequiresKeyAndNotLike(t *testing.T) { + queryBuilder := NewAppListingRepositoryQueryBuilder(zap.NewNop().Sugar()) + value := "may" + + predicate, queryParams := queryBuilder.buildTagFilterPredicate(TagFilter{ + Key: "owner", + Operator: TagFilterOperatorDoesNotContain, + Value: &value, + }) + + assert.Equal(t, "EXISTS (SELECT 1 FROM app_label al WHERE al.app_id = a.id and al.key = ? and al.value NOT LIKE ? ESCAPE '\\')", predicate) + assert.Equal(t, []interface{}{"owner", "%may%"}, queryParams) +} diff --git a/pkg/app/AppListingService.go b/pkg/app/AppListingService.go index 86f84f2ef3..4fc3312677 100644 --- a/pkg/app/AppListingService.go +++ b/pkg/app/AppListingService.go @@ -70,6 +70,8 @@ type AppListingService interface { ISLastReleaseStopType(appId, envId int) (bool, error) ISLastReleaseStopTypeV2(pipelineIds []int) (map[int]bool, error) GetReleaseCount(appId, envId int) (int, error) + ValidateTagFilters(tagFilters []helper.TagFilter) error + NormalizeTagFilters(tagFilters []helper.TagFilter) []helper.TagFilter FetchAppsByEnvironmentV2(fetchAppListingRequest FetchAppListingRequest, w http.ResponseWriter, r *http.Request, token string) ([]*AppView.AppEnvironmentContainer, int, error) FetchOverviewAppsByEnvironment(envId, limit, offset int) (*OverviewAppsByEnvironmentBean, error) @@ -82,18 +84,19 @@ const ( ) type FetchAppListingRequest struct { - Environments []int `json:"environments"` - Statuses []string `json:"statuses"` - Teams []int `json:"teams"` - AppNameSearch string `json:"appNameSearch"` - SortOrder helper.SortOrder `json:"sortOrder"` - SortBy helper.SortBy `json:"sortBy"` - Offset int `json:"offset"` - Size int `json:"size"` - DeploymentGroupId int `json:"deploymentGroupId"` - Namespaces []string `json:"namespaces"` // {clusterId}_{namespace} - AppStatuses []string `json:"appStatuses"` - AppIds []int `json:"-"` // internal use only + Environments []int `json:"environments"` + Statuses []string `json:"statuses"` + Teams []int `json:"teams"` + TagFilters []helper.TagFilter `json:"tagFilters" validate:"omitempty,dive"` + AppNameSearch string `json:"appNameSearch"` + SortOrder helper.SortOrder `json:"sortOrder"` + SortBy helper.SortBy `json:"sortBy"` + Offset int `json:"offset"` + Size int `json:"size"` + DeploymentGroupId int `json:"deploymentGroupId"` + Namespaces []string `json:"namespaces"` // {clusterId}_{namespace} + AppStatuses []string `json:"appStatuses"` + AppIds []int `json:"-"` // internal use only // IsClusterOrNamespaceSelected bool `json:"isClusterOrNamespaceSelected"` } type AppNameTypeIdContainer struct { @@ -102,6 +105,55 @@ type AppNameTypeIdContainer struct { AppId int `json:"appId"` } +// ValidateTagFilters validates each tag filter row. +// Rules: +// 1) key must be present. +// 2) operator must be one of the supported backend operators. +// 3) for EXISTS/DOES_NOT_EXIST, value must not be sent. +// 4) for EQUALS/DOES_NOT_EQUAL/CONTAINS/DOES_NOT_CONTAIN, value must be non-empty. +func ValidateTagFilters(tagFilters []helper.TagFilter) error { + for index, tagFilter := range tagFilters { + if len(strings.TrimSpace(tagFilter.Key)) == 0 { + return fmt.Errorf("tagFilters[%d].key is required", index) + } + if !tagFilter.Operator.IsValid() { + return fmt.Errorf("tagFilters[%d].operator is invalid: %s", index, tagFilter.Operator) + } + switch tagFilter.Operator { + case helper.TagFilterOperatorExists, helper.TagFilterOperatorDoesNotExist: + if tagFilter.Value != nil { + return fmt.Errorf("tagFilters[%d].value must be empty for operator %s", index, tagFilter.Operator) + } + default: + if tagFilter.Value == nil || len(*tagFilter.Value) == 0 { + return fmt.Errorf("tagFilters[%d].value is required for operator %s", index, tagFilter.Operator) + } + } + } + return nil +} + +// NormalizeTagFilters normalizes tag filter rows after validation. +func NormalizeTagFilters(tagFilters []helper.TagFilter) []helper.TagFilter { + if len(tagFilters) == 0 { + return tagFilters + } + normalizedFilters := make([]helper.TagFilter, 0, len(tagFilters)) + for _, tagFilter := range tagFilters { + tagFilter.Key = strings.TrimSpace(tagFilter.Key) + normalizedFilters = append(normalizedFilters, tagFilter) + } + return normalizedFilters +} + +func (impl AppListingServiceImpl) ValidateTagFilters(tagFilters []helper.TagFilter) error { + return ValidateTagFilters(tagFilters) +} + +func (impl AppListingServiceImpl) NormalizeTagFilters(tagFilters []helper.TagFilter) []helper.TagFilter { + return NormalizeTagFilters(tagFilters) +} + func (req FetchAppListingRequest) GetNamespaceClusterMapping() (namespaceClusterPair []*repository2.ClusterNamespacePair, clusterIds []int, err error) { for _, ns := range req.Namespaces { items := strings.Split(ns, "_") @@ -408,6 +460,7 @@ func (impl AppListingServiceImpl) FetchAppsByEnvironmentV2(fetchAppListingReques Size: fetchAppListingRequest.Size, DeploymentGroupId: fetchAppListingRequest.DeploymentGroupId, AppStatuses: fetchAppListingRequest.AppStatuses, + TagFilters: fetchAppListingRequest.TagFilters, AppIds: fetchAppListingRequest.AppIds, } _, span := otel.Tracer("appListingRepository").Start(r.Context(), "FetchAppsByEnvironment") diff --git a/pkg/app/AppListingService_tag_filter_test.go b/pkg/app/AppListingService_tag_filter_test.go new file mode 100644 index 0000000000..59b486a384 --- /dev/null +++ b/pkg/app/AppListingService_tag_filter_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "testing" + + "github.com/devtron-labs/devtron/internal/sql/repository/helper" + "github.com/stretchr/testify/assert" +) + +func strPointer(value string) *string { + return &value +} + +func TestValidateTagFilters_EqualsRequiresValue(t *testing.T) { + err := ValidateTagFilters([]helper.TagFilter{ + {Key: "owner", Operator: helper.TagFilterOperatorEquals, Value: nil}, + }) + + assert.Error(t, err) + assert.Equal(t, "tagFilters[0].value is required for operator EQUALS", err.Error()) +} + +func TestValidateTagFilters_EqualsRejectsEmptyString(t *testing.T) { + err := ValidateTagFilters([]helper.TagFilter{ + {Key: "owner", Operator: helper.TagFilterOperatorEquals, Value: strPointer("")}, + }) + + assert.Error(t, err) + assert.Equal(t, "tagFilters[0].value is required for operator EQUALS", err.Error()) +} + +func TestValidateTagFilters_ContainsRequiresValue(t *testing.T) { + err := ValidateTagFilters([]helper.TagFilter{ + {Key: "owner", Operator: helper.TagFilterOperatorContains, Value: nil}, + }) + + assert.Error(t, err) + assert.Equal(t, "tagFilters[0].value is required for operator CONTAINS", err.Error()) +} + +func TestValidateTagFilters_EmptyKeyReturnsError(t *testing.T) { + err := ValidateTagFilters([]helper.TagFilter{ + {Key: " ", Operator: helper.TagFilterOperatorEquals, Value: strPointer("James")}, + }) + + assert.Error(t, err) + assert.Equal(t, "tagFilters[0].key is required", err.Error()) +} + +func TestValidateTagFilters_InvalidOperatorReturnsError(t *testing.T) { + err := ValidateTagFilters([]helper.TagFilter{ + {Key: "owner", Operator: helper.TagFilterOperator("INVALID"), Value: strPointer("James")}, + }) + + assert.Error(t, err) + assert.Equal(t, "tagFilters[0].operator is invalid: INVALID", err.Error()) +} + +func TestValidateTagFilters_ExistsAllowsNilValueOnly(t *testing.T) { + err := ValidateTagFilters([]helper.TagFilter{ + {Key: "owner", Operator: helper.TagFilterOperatorExists, Value: nil}, + }) + + assert.NoError(t, err) +} + +func TestValidateTagFilters_ExistsRejectsProvidedValue(t *testing.T) { + err := ValidateTagFilters([]helper.TagFilter{ + {Key: "owner", Operator: helper.TagFilterOperatorExists, Value: strPointer("James")}, + }) + + assert.Error(t, err) + assert.Equal(t, "tagFilters[0].value must be empty for operator EXISTS", err.Error()) +} + +func TestValidateTagFilters_DoesNotExistRejectsProvidedValue(t *testing.T) { + err := ValidateTagFilters([]helper.TagFilter{ + {Key: "owner", Operator: helper.TagFilterOperatorDoesNotExist, Value: strPointer("")}, + }) + + assert.Error(t, err) + assert.Equal(t, "tagFilters[0].value must be empty for operator DOES_NOT_EXIST", err.Error()) +} + +func TestNormalizeTagFilters_TrimsKey(t *testing.T) { + filters := []helper.TagFilter{ + {Key: " owner ", Operator: helper.TagFilterOperatorEquals, Value: strPointer("James")}, + } + + normalizedFilters := NormalizeTagFilters(filters) + + assert.Len(t, normalizedFilters, 1) + assert.Equal(t, "owner", normalizedFilters[0].Key) + // Ensure input is not modified by normalization. + assert.Equal(t, " owner ", filters[0].Key) +} diff --git a/pkg/app/mocks/AppListingService.go b/pkg/app/mocks/AppListingService.go index b321294fa1..6cab7df9c9 100644 --- a/pkg/app/mocks/AppListingService.go +++ b/pkg/app/mocks/AppListingService.go @@ -4,6 +4,7 @@ package mocks import ( bean "github.com/devtron-labs/devtron/api/bean/AppView" + helper "github.com/devtron-labs/devtron/internal/sql/repository/helper" app "github.com/devtron-labs/devtron/pkg/app" context "context" @@ -313,6 +314,16 @@ func (_m *AppListingService) FetchAppsByEnvironmentV2(fetchAppListingRequest app return r0, r1, r2 } +// NormalizeTagFilters provides a mock function with given fields: tagFilters +func (_m *AppListingService) NormalizeTagFilters(tagFilters []helper.TagFilter) []helper.TagFilter { + return app.NormalizeTagFilters(tagFilters) +} + +// ValidateTagFilters provides a mock function with given fields: tagFilters +func (_m *AppListingService) ValidateTagFilters(tagFilters []helper.TagFilter) error { + return app.ValidateTagFilters(tagFilters) +} + // FetchJobs provides a mock function with given fields: fetchJobListingRequest func (_m *AppListingService) FetchJobs(fetchJobListingRequest app.FetchAppListingRequest) ([]*bean.JobContainer, error) { ret := _m.Called(fetchJobListingRequest) diff --git a/scripts/sql/35504400_app_label_filter_index.down.sql b/scripts/sql/35504400_app_label_filter_index.down.sql new file mode 100644 index 0000000000..f6be7bb1f3 --- /dev/null +++ b/scripts/sql/35504400_app_label_filter_index.down.sql @@ -0,0 +1,5 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +DROP INDEX IF EXISTS idx_app_label_app_id_key_value; diff --git a/scripts/sql/35504400_app_label_filter_index.up.sql b/scripts/sql/35504400_app_label_filter_index.up.sql new file mode 100644 index 0000000000..a4518f30ce --- /dev/null +++ b/scripts/sql/35504400_app_label_filter_index.up.sql @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2024. Devtron Inc. + */ + +-- Index to support app listing tag filters based on app labels. +-- This keeps EXISTS/NOT EXISTS predicates fast for app_id + key lookups, +-- and also helps equality checks on value. +CREATE INDEX IF NOT EXISTS idx_app_label_app_id_key_value + ON public.app_label USING BTREE (app_id, key, value);