From 719ca0bee167fef12b816aec24f90f14d402b1ca Mon Sep 17 00:00:00 2001 From: Thomas Krampl Date: Fri, 11 Sep 2026 13:08:13 +0200 Subject: [PATCH] Refactor activity log facet queries by scope Add dedicated facet/list SQL paths for team, resource, tenant-wide, and resource-without-team scopes, and mark tenant queries explicitly as tenant-wide in `ActivityLogScope`. Also add integration coverage for facet `resourceTypes` across activity log consumers (tenant, reconcilers, service accounts, valkey) and add deployment facet expectations. Introduce migration 0074 to rebuild activity log indexes to match the new scoped query patterns. --- integration_tests/activitylog_filter.lua | 12 + integration_tests/reconcilers.lua | 16 + .../serviceaccount_activitylog.lua | 14 + integration_tests/tenant_activitylog.lua | 94 +- integration_tests/valkey_crud.lua | 56 ++ .../activitylogsql/activitylog.sql.go | 810 ++++++++++++++++-- .../activitylog/activitylogsql/querier.go | 10 +- internal/activitylog/facets.go | 198 ++++- internal/activitylog/model.go | 1 + internal/activitylog/queries.go | 43 +- internal/activitylog/queries/activitylog.sql | 424 ++++++++- .../0074_activity_log_tenant_indexes.sql | 50 ++ 12 files changed, 1602 insertions(+), 126 deletions(-) create mode 100644 internal/database/migrations/0074_activity_log_tenant_indexes.sql diff --git a/integration_tests/activitylog_filter.lua b/integration_tests/activitylog_filter.lua index b386e6f65..53d45ba47 100644 --- a/integration_tests/activitylog_filter.lua +++ b/integration_tests/activitylog_filter.lua @@ -102,6 +102,12 @@ Test.gql("Query activitylog list", function(t) resourceType resourceName } + facets { + resourceTypes { + resourceType + count + } + } } } } @@ -129,6 +135,12 @@ Test.gql("Query activitylog list", function(t) resourceName = "app", }, }, + facets = { + resourceTypes = { + { resourceType = "APP", count = 1 }, + { resourceType = "VALKEY", count = 1 }, + }, + }, }, }, }, diff --git a/integration_tests/reconcilers.lua b/integration_tests/reconcilers.lua index 6d4c3315a..dc4d6d6b4 100644 --- a/integration_tests/reconcilers.lua +++ b/integration_tests/reconcilers.lua @@ -283,6 +283,12 @@ Test.gql("list reconcilers after modifications", function(t) } } } + facets { + resourceTypes { + resourceType + count + } + } } name enabled @@ -315,6 +321,11 @@ Test.gql("list reconcilers after modifications", function(t) }, }, }, + facets = { + resourceTypes = { + { resourceType = "RECONCILER", count = 3 }, + }, + }, }, name = "reconciler-1", enabled = true, @@ -324,6 +335,11 @@ Test.gql("list reconcilers after modifications", function(t) nodes = { { message = "Disable reconciler" }, }, + facets = { + resourceTypes = { + { resourceType = "RECONCILER", count = 1 }, + }, + }, }, name = "reconciler-2", enabled = false, diff --git a/integration_tests/serviceaccount_activitylog.lua b/integration_tests/serviceaccount_activitylog.lua index 70cfaf08e..8e461e686 100644 --- a/integration_tests/serviceaccount_activitylog.lua +++ b/integration_tests/serviceaccount_activitylog.lua @@ -126,6 +126,12 @@ Test.gql("Team-scoped account only sees its own entries", function(t) pageInfo { totalCount } + facets { + resourceTypes { + resourceType + count + } + } } } } @@ -150,6 +156,14 @@ Test.gql("Team-scoped account only sees its own entries", function(t) pageInfo = { totalCount = 2, }, + facets = { + resourceTypes = { + { + resourceType = "SERVICE_ACCOUNT", + count = 2, + }, + }, + }, }, }, }, diff --git a/integration_tests/tenant_activitylog.lua b/integration_tests/tenant_activitylog.lua index 02b752baf..42d2fdbd3 100644 --- a/integration_tests/tenant_activitylog.lua +++ b/integration_tests/tenant_activitylog.lua @@ -51,6 +51,85 @@ Test.gql("Create repository event for team two", function(t) } end) +Helper.SQLExec([[ + INSERT INTO activity_log_entries (actor, action, resource_type, resource_name, team_slug, environment, data) + VALUES ('deployer', 'DEPLOYMENT', 'APP', 'deployed-app', 'slug-1', 'dev', '{}') +]]) + +Test.gql("Tenant activity log includes deployments", function(t) + t.addHeader("x-user-email", admin:email()) + + t.query [[ + query { + activityLog(first: 10, filter: { activityTypes: [DEPLOYMENT] }) { + nodes { + resourceName + } + pageInfo { + totalCount + } + facets { + activityTypes { + activityType + count + } + resourceTypes { + resourceType + count + } + environments { + value + count + } + } + } + } + ]] + + t.check { + data = { + activityLog = { + nodes = { + { + resourceName = "deployed-app", + }, + }, + pageInfo = { + totalCount = 1, + }, + facets = { + activityTypes = { + { + activityType = "DEPLOYMENT", + count = 1, + }, + { + activityType = "REPOSITORY_ADDED", + count = 0, + }, + }, + resourceTypes = { + { + resourceType = "APP", + count = 1, + }, + { + resourceType = "REPOSITORY", + count = 0, + }, + }, + environments = { + { + value = "dev", + count = 1, + }, + }, + }, + }, + }, + } +end) + Test.gql("Tenant activity log returns facets and pagination metadata", function(t) t.addHeader("x-user-email", admin:email()) @@ -102,18 +181,31 @@ Test.gql("Tenant activity log returns facets and pagination metadata", function( }, facets = { activityTypes = { + { + activityType = "DEPLOYMENT", + count = 0, + }, { activityType = "REPOSITORY_ADDED", count = 2, }, }, resourceTypes = { + { + resourceType = "APP", + count = 0, + }, { resourceType = "REPOSITORY", count = 2, }, }, - environments = {}, + environments = { + { + value = "dev", + count = 0, + }, + }, }, }, }, diff --git a/integration_tests/valkey_crud.lua b/integration_tests/valkey_crud.lua index fda8a679c..c558c1262 100644 --- a/integration_tests/valkey_crud.lua +++ b/integration_tests/valkey_crud.lua @@ -497,6 +497,62 @@ Test.gql("List valkeys for team", function(t) } end) +Test.gql("List activity log facets for valkey", function(t) + t.addHeader("x-user-email", user:email()) + + t.query [[ + { + team(slug: "someteamname") { + environment(name: "dev") { + valkey(name: "foobar") { + activityLog { + facets { + activityTypes { + activityType + count + } + resourceTypes { + resourceType + count + } + environments { + value + count + } + } + } + } + } + } + } + ]] + + t.check { + data = { + team = { + environment = { + valkey = { + activityLog = { + facets = { + activityTypes = { + { activityType = "VALKEY_CREATED", count = 1 }, + { activityType = "VALKEY_UPDATED", count = 1 }, + }, + resourceTypes = { + { resourceType = "VALKEY", count = 2 }, + }, + environments = { + { value = "dev", count = 2 }, + }, + }, + }, + }, + }, + }, + }, + } +end) + Test.gql("Update Valkey with tier and memory equivalent to hobbyist plan", function(t) t.addHeader("x-user-email", user:email()) t.query [[ diff --git a/internal/activitylog/activitylogsql/activitylog.sql.go b/internal/activitylog/activitylogsql/activitylog.sql.go index 85a10f229..bee9afaf5 100644 --- a/internal/activitylog/activitylogsql/activitylog.sql.go +++ b/internal/activitylog/activitylogsql/activitylog.sql.go @@ -57,12 +57,441 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) error { return err } -const facetsForActivityTypes = `-- name: FacetsForActivityTypes :many +const facetsForResource = `-- name: FacetsForResource :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) FILTER ( + WHERE + ( + $1::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($1::TEXT[]) + ) + AND ( + $2::TEXT[] IS NULL + OR resource_type = ANY ($2::TEXT[]) + ) + AND ( + $3::TEXT[] IS NULL + OR environment = ANY ($3::TEXT[]) + ) + AND ( + $4::TIMESTAMPTZ IS NULL + OR created_at >= $4::TIMESTAMPTZ + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at < $5::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + resource_type = $6 + AND resource_name = $7 + AND ( + $8::TIMESTAMPTZ IS NULL + OR created_at >= $8::TIMESTAMPTZ + ) + AND ( + $9::TIMESTAMPTZ IS NULL + OR created_at < $9::TIMESTAMPTZ + ) +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +` + +type FacetsForResourceParams struct { + Filter []string + FilterResourceTypes []string + FilterEnvironments []string + FilterFrom pgtype.Timestamptz + FilterTo pgtype.Timestamptz + ResourceType string + ResourceName string + From pgtype.Timestamptz + To pgtype.Timestamptz +} + +type FacetsForResourceRow struct { + ResourceType string + Action string + Environment string + FilteredCount int64 +} + +func (q *Queries) FacetsForResource(ctx context.Context, arg FacetsForResourceParams) ([]*FacetsForResourceRow, error) { + rows, err := q.db.Query(ctx, facetsForResource, + arg.Filter, + arg.FilterResourceTypes, + arg.FilterEnvironments, + arg.FilterFrom, + arg.FilterTo, + arg.ResourceType, + arg.ResourceName, + arg.From, + arg.To, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*FacetsForResourceRow{} + for rows.Next() { + var i FacetsForResourceRow + if err := rows.Scan( + &i.ResourceType, + &i.Action, + &i.Environment, + &i.FilteredCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const facetsForResourceAndTeam = `-- name: FacetsForResourceAndTeam :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) FILTER ( + WHERE + ( + $1::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($1::TEXT[]) + ) + AND ( + $2::TEXT[] IS NULL + OR resource_type = ANY ($2::TEXT[]) + ) + AND ( + $3::TEXT[] IS NULL + OR environment = ANY ($3::TEXT[]) + ) + AND ( + $4::TIMESTAMPTZ IS NULL + OR created_at >= $4::TIMESTAMPTZ + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at < $5::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + resource_type = $6 + AND resource_name = $7 + AND team_slug = $8 + AND ( + $9::TIMESTAMPTZ IS NULL + OR created_at >= $9::TIMESTAMPTZ + ) + AND ( + $10::TIMESTAMPTZ IS NULL + OR created_at < $10::TIMESTAMPTZ + ) +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +` + +type FacetsForResourceAndTeamParams struct { + Filter []string + FilterResourceTypes []string + FilterEnvironments []string + FilterFrom pgtype.Timestamptz + FilterTo pgtype.Timestamptz + ResourceType string + ResourceName string + TeamSlug *slug.Slug + From pgtype.Timestamptz + To pgtype.Timestamptz +} + +type FacetsForResourceAndTeamRow struct { + ResourceType string + Action string + Environment string + FilteredCount int64 +} + +func (q *Queries) FacetsForResourceAndTeam(ctx context.Context, arg FacetsForResourceAndTeamParams) ([]*FacetsForResourceAndTeamRow, error) { + rows, err := q.db.Query(ctx, facetsForResourceAndTeam, + arg.Filter, + arg.FilterResourceTypes, + arg.FilterEnvironments, + arg.FilterFrom, + arg.FilterTo, + arg.ResourceType, + arg.ResourceName, + arg.TeamSlug, + arg.From, + arg.To, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*FacetsForResourceAndTeamRow{} + for rows.Next() { + var i FacetsForResourceAndTeamRow + if err := rows.Scan( + &i.ResourceType, + &i.Action, + &i.Environment, + &i.FilteredCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const facetsForResourceTeamAndEnvironment = `-- name: FacetsForResourceTeamAndEnvironment :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) FILTER ( + WHERE + ( + $1::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($1::TEXT[]) + ) + AND ( + $2::TEXT[] IS NULL + OR resource_type = ANY ($2::TEXT[]) + ) + AND ( + $3::TEXT[] IS NULL + OR environment = ANY ($3::TEXT[]) + ) + AND ( + $4::TIMESTAMPTZ IS NULL + OR created_at >= $4::TIMESTAMPTZ + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at < $5::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + resource_type = $6 + AND resource_name = $7 + AND team_slug = $8 + AND environment = $9 + AND ( + $10::TIMESTAMPTZ IS NULL + OR created_at >= $10::TIMESTAMPTZ + ) + AND ( + $11::TIMESTAMPTZ IS NULL + OR created_at < $11::TIMESTAMPTZ + ) +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +` + +type FacetsForResourceTeamAndEnvironmentParams struct { + Filter []string + FilterResourceTypes []string + FilterEnvironments []string + FilterFrom pgtype.Timestamptz + FilterTo pgtype.Timestamptz + ResourceType string + ResourceName string + TeamSlug *slug.Slug + EnvironmentName *string + From pgtype.Timestamptz + To pgtype.Timestamptz +} + +type FacetsForResourceTeamAndEnvironmentRow struct { + ResourceType string + Action string + Environment string + FilteredCount int64 +} + +func (q *Queries) FacetsForResourceTeamAndEnvironment(ctx context.Context, arg FacetsForResourceTeamAndEnvironmentParams) ([]*FacetsForResourceTeamAndEnvironmentRow, error) { + rows, err := q.db.Query(ctx, facetsForResourceTeamAndEnvironment, + arg.Filter, + arg.FilterResourceTypes, + arg.FilterEnvironments, + arg.FilterFrom, + arg.FilterTo, + arg.ResourceType, + arg.ResourceName, + arg.TeamSlug, + arg.EnvironmentName, + arg.From, + arg.To, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*FacetsForResourceTeamAndEnvironmentRow{} + for rows.Next() { + var i FacetsForResourceTeamAndEnvironmentRow + if err := rows.Scan( + &i.ResourceType, + &i.Action, + &i.Environment, + &i.FilteredCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const facetsForResourceWithoutTeam = `-- name: FacetsForResourceWithoutTeam :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) FILTER ( + WHERE + ( + $1::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($1::TEXT[]) + ) + AND ( + $2::TEXT[] IS NULL + OR resource_type = ANY ($2::TEXT[]) + ) + AND ( + $3::TEXT[] IS NULL + OR environment = ANY ($3::TEXT[]) + ) + AND ( + $4::TIMESTAMPTZ IS NULL + OR created_at >= $4::TIMESTAMPTZ + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at < $5::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + resource_type = $6 + AND resource_name = $7 + AND team_slug IS NULL + AND ( + $8::TIMESTAMPTZ IS NULL + OR created_at >= $8::TIMESTAMPTZ + ) + AND ( + $9::TIMESTAMPTZ IS NULL + OR created_at < $9::TIMESTAMPTZ + ) +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +` + +type FacetsForResourceWithoutTeamParams struct { + Filter []string + FilterResourceTypes []string + FilterEnvironments []string + FilterFrom pgtype.Timestamptz + FilterTo pgtype.Timestamptz + ResourceType string + ResourceName string + From pgtype.Timestamptz + To pgtype.Timestamptz +} + +type FacetsForResourceWithoutTeamRow struct { + ResourceType string + Action string + Environment string + FilteredCount int64 +} + +func (q *Queries) FacetsForResourceWithoutTeam(ctx context.Context, arg FacetsForResourceWithoutTeamParams) ([]*FacetsForResourceWithoutTeamRow, error) { + rows, err := q.db.Query(ctx, facetsForResourceWithoutTeam, + arg.Filter, + arg.FilterResourceTypes, + arg.FilterEnvironments, + arg.FilterFrom, + arg.FilterTo, + arg.ResourceType, + arg.ResourceName, + arg.From, + arg.To, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*FacetsForResourceWithoutTeamRow{} + for rows.Next() { + var i FacetsForResourceWithoutTeamRow + if err := rows.Scan( + &i.ResourceType, + &i.Action, + &i.Environment, + &i.FilteredCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const facetsForTeam = `-- name: FacetsForTeam :many SELECT resource_type, action, COALESCE(environment, '') AS environment, - COUNT(*) AS total_count, COUNT(*) FILTER ( WHERE ( @@ -89,33 +518,116 @@ SELECT FROM activity_log_combined_view WHERE - ( - CASE - -- match_null_team keeps facet counts consistent with ListForResourceAndTeam. - WHEN $6::BOOLEAN THEN team_slug IS NOT DISTINCT FROM $7::TEXT - WHEN $7::TEXT IS NULL THEN TRUE - ELSE team_slug = $7 - END - ) + team_slug = $6 AND ( - $8::TEXT IS NULL - OR resource_type = $8 + $7::TIMESTAMPTZ IS NULL + OR created_at >= $7::TIMESTAMPTZ ) AND ( - $9::TEXT IS NULL - OR resource_name = $9 + $8::TIMESTAMPTZ IS NULL + OR created_at < $8::TIMESTAMPTZ ) - AND ( - $10::TEXT IS NULL - OR environment = $10 +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +` + +type FacetsForTeamParams struct { + Filter []string + FilterResourceTypes []string + FilterEnvironments []string + FilterFrom pgtype.Timestamptz + FilterTo pgtype.Timestamptz + TeamSlug *slug.Slug + From pgtype.Timestamptz + To pgtype.Timestamptz +} + +type FacetsForTeamRow struct { + ResourceType string + Action string + Environment string + FilteredCount int64 +} + +func (q *Queries) FacetsForTeam(ctx context.Context, arg FacetsForTeamParams) ([]*FacetsForTeamRow, error) { + rows, err := q.db.Query(ctx, facetsForTeam, + arg.Filter, + arg.FilterResourceTypes, + arg.FilterEnvironments, + arg.FilterFrom, + arg.FilterTo, + arg.TeamSlug, + arg.From, + arg.To, ) - AND ( - $11::TIMESTAMPTZ IS NULL - OR created_at >= $11::TIMESTAMPTZ + if err != nil { + return nil, err + } + defer rows.Close() + items := []*FacetsForTeamRow{} + for rows.Next() { + var i FacetsForTeamRow + if err := rows.Scan( + &i.ResourceType, + &i.Action, + &i.Environment, + &i.FilteredCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const facetsForTenantActivityTypes = `-- name: FacetsForTenantActivityTypes :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) AS total_count, + COUNT(*) FILTER ( + WHERE + ( + $1::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($1::TEXT[]) + ) + AND ( + $2::TEXT[] IS NULL + OR resource_type = ANY ($2::TEXT[]) + ) + AND ( + $3::TEXT[] IS NULL + OR environment = ANY ($3::TEXT[]) + ) + AND ( + $4::TIMESTAMPTZ IS NULL + OR created_at >= $4::TIMESTAMPTZ + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at < $5::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + ( + $6::TIMESTAMPTZ IS NULL + OR created_at >= $6::TIMESTAMPTZ ) AND ( - $12::TIMESTAMPTZ IS NULL - OR created_at < $12::TIMESTAMPTZ + $7::TIMESTAMPTZ IS NULL + OR created_at < $7::TIMESTAMPTZ ) GROUP BY resource_type, @@ -127,22 +639,17 @@ ORDER BY environment ` -type FacetsForActivityTypesParams struct { +type FacetsForTenantActivityTypesParams struct { Filter []string FilterResourceTypes []string FilterEnvironments []string FilterFrom pgtype.Timestamptz FilterTo pgtype.Timestamptz - MatchNullTeam bool - TeamSlug *string - ResourceType *string - ResourceName *string - EnvironmentName *string From pgtype.Timestamptz To pgtype.Timestamptz } -type FacetsForActivityTypesRow struct { +type FacetsForTenantActivityTypesRow struct { ResourceType string Action string Environment string @@ -150,18 +657,13 @@ type FacetsForActivityTypesRow struct { FilteredCount int64 } -func (q *Queries) FacetsForActivityTypes(ctx context.Context, arg FacetsForActivityTypesParams) ([]*FacetsForActivityTypesRow, error) { - rows, err := q.db.Query(ctx, facetsForActivityTypes, +func (q *Queries) FacetsForTenantActivityTypes(ctx context.Context, arg FacetsForTenantActivityTypesParams) ([]*FacetsForTenantActivityTypesRow, error) { + rows, err := q.db.Query(ctx, facetsForTenantActivityTypes, arg.Filter, arg.FilterResourceTypes, arg.FilterEnvironments, arg.FilterFrom, arg.FilterTo, - arg.MatchNullTeam, - arg.TeamSlug, - arg.ResourceType, - arg.ResourceName, - arg.EnvironmentName, arg.From, arg.To, ) @@ -169,9 +671,9 @@ func (q *Queries) FacetsForActivityTypes(ctx context.Context, arg FacetsForActiv return nil, err } defer rows.Close() - items := []*FacetsForActivityTypesRow{} + items := []*FacetsForTenantActivityTypesRow{} for rows.Next() { - var i FacetsForActivityTypesRow + var i FacetsForTenantActivityTypesRow if err := rows.Scan( &i.ResourceType, &i.Action, @@ -257,36 +759,69 @@ func (q *Queries) ListByIDs(ctx context.Context, ids []uuid.UUID) ([]*ActivityLo } const listForResource = `-- name: ListForResource :many +WITH + matching_entries AS ( + SELECT + COUNT(*) AS total_count + FROM + activity_log_combined_view + WHERE + resource_type = $1 + AND resource_name = $2 + AND ( + $3::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($3::TEXT[]) + ) + AND ( + $4::TEXT[] IS NULL + OR resource_type = ANY ($4::TEXT[]) + ) + AND ( + $5::TEXT[] IS NULL + OR environment = ANY ($5::TEXT[]) + ) + AND ( + $6::TIMESTAMPTZ IS NULL + OR created_at >= $6::TIMESTAMPTZ + ) + AND ( + $7::TIMESTAMPTZ IS NULL + OR created_at < $7::TIMESTAMPTZ + ) + ) SELECT activity_log_combined_view.id, activity_log_combined_view.created_at, activity_log_combined_view.actor, activity_log_combined_view.action, activity_log_combined_view.resource_type, activity_log_combined_view.resource_name, activity_log_combined_view.team_slug, activity_log_combined_view.data, activity_log_combined_view.environment, - COUNT(*) OVER () AS total_count + matching_entries.total_count FROM activity_log_combined_view + CROSS JOIN matching_entries WHERE - resource_type = $1 - AND resource_name = $2 + activity_log_combined_view.resource_type = $1 + AND activity_log_combined_view.resource_name = $2 AND ( $3::TEXT[] IS NULL - OR (resource_type || ':' || action) = ANY ($3::TEXT[]) + OR ( + activity_log_combined_view.resource_type || ':' || activity_log_combined_view.action + ) = ANY ($3::TEXT[]) ) AND ( $4::TEXT[] IS NULL - OR resource_type = ANY ($4::TEXT[]) + OR activity_log_combined_view.resource_type = ANY ($4::TEXT[]) ) AND ( $5::TEXT[] IS NULL - OR environment = ANY ($5::TEXT[]) + OR activity_log_combined_view.environment = ANY ($5::TEXT[]) ) AND ( $6::TIMESTAMPTZ IS NULL - OR created_at >= $6::TIMESTAMPTZ + OR activity_log_combined_view.created_at >= $6::TIMESTAMPTZ ) AND ( $7::TIMESTAMPTZ IS NULL - OR created_at < $7::TIMESTAMPTZ + OR activity_log_combined_view.created_at < $7::TIMESTAMPTZ ) ORDER BY - created_at DESC + activity_log_combined_view.created_at DESC LIMIT $9 OFFSET @@ -360,7 +895,7 @@ FROM WHERE resource_type = $1 AND resource_name = $2 - AND team_slug IS NOT DISTINCT FROM $3::TEXT + AND team_slug = $3 AND ( $4::TEXT[] IS NULL OR (resource_type || ':' || action) = ANY ($4::TEXT[]) @@ -392,7 +927,7 @@ OFFSET type ListForResourceAndTeamParams struct { ResourceType string ResourceName string - TeamSlug *string + TeamSlug *slug.Slug Filter []string ResourceTypes []string Environments []string @@ -407,8 +942,6 @@ type ListForResourceAndTeamRow struct { TotalCount int64 } -// A NULL team_slug matches tenant-wide resources only, not every team, mirroring the NULLS NOT DISTINCT -// index on service_accounts (name, team_slug). func (q *Queries) ListForResourceAndTeam(ctx context.Context, arg ListForResourceAndTeamParams) ([]*ListForResourceAndTeamRow, error) { rows, err := q.db.Query(ctx, listForResourceAndTeam, arg.ResourceType, @@ -552,36 +1085,164 @@ func (q *Queries) ListForResourceTeamAndEnvironment(ctx context.Context, arg Lis return items, nil } -const listForTeam = `-- name: ListForTeam :many +const listForResourceWithoutTeam = `-- name: ListForResourceWithoutTeam :many SELECT activity_log_combined_view.id, activity_log_combined_view.created_at, activity_log_combined_view.actor, activity_log_combined_view.action, activity_log_combined_view.resource_type, activity_log_combined_view.resource_name, activity_log_combined_view.team_slug, activity_log_combined_view.data, activity_log_combined_view.environment, COUNT(*) OVER () AS total_count FROM activity_log_combined_view WHERE - team_slug = $1 + resource_type = $1 + AND resource_name = $2 + AND team_slug IS NULL + AND ( + $3::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($3::TEXT[]) + ) + AND ( + $4::TEXT[] IS NULL + OR resource_type = ANY ($4::TEXT[]) + ) + AND ( + $5::TEXT[] IS NULL + OR environment = ANY ($5::TEXT[]) + ) + AND ( + $6::TIMESTAMPTZ IS NULL + OR created_at >= $6::TIMESTAMPTZ + ) + AND ( + $7::TIMESTAMPTZ IS NULL + OR created_at < $7::TIMESTAMPTZ + ) +ORDER BY + created_at DESC +LIMIT + $9 +OFFSET + $8 +` + +type ListForResourceWithoutTeamParams struct { + ResourceType string + ResourceName string + Filter []string + ResourceTypes []string + Environments []string + From pgtype.Timestamptz + To pgtype.Timestamptz + Offset int32 + Limit int32 +} + +type ListForResourceWithoutTeamRow struct { + ActivityLogCombinedView ActivityLogCombinedView + TotalCount int64 +} + +func (q *Queries) ListForResourceWithoutTeam(ctx context.Context, arg ListForResourceWithoutTeamParams) ([]*ListForResourceWithoutTeamRow, error) { + rows, err := q.db.Query(ctx, listForResourceWithoutTeam, + arg.ResourceType, + arg.ResourceName, + arg.Filter, + arg.ResourceTypes, + arg.Environments, + arg.From, + arg.To, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListForResourceWithoutTeamRow{} + for rows.Next() { + var i ListForResourceWithoutTeamRow + if err := rows.Scan( + &i.ActivityLogCombinedView.ID, + &i.ActivityLogCombinedView.CreatedAt, + &i.ActivityLogCombinedView.Actor, + &i.ActivityLogCombinedView.Action, + &i.ActivityLogCombinedView.ResourceType, + &i.ActivityLogCombinedView.ResourceName, + &i.ActivityLogCombinedView.TeamSlug, + &i.ActivityLogCombinedView.Data, + &i.ActivityLogCombinedView.Environment, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listForTeam = `-- name: ListForTeam :many +WITH + matching_entries AS ( + SELECT + COUNT(*) AS total_count + FROM + activity_log_combined_view + WHERE + team_slug = $1 + AND ( + $2::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($2::TEXT[]) + ) + AND ( + $3::TEXT[] IS NULL + OR resource_type = ANY ($3::TEXT[]) + ) + AND ( + $4::TEXT[] IS NULL + OR environment = ANY ($4::TEXT[]) + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at >= $5::TIMESTAMPTZ + ) + AND ( + $6::TIMESTAMPTZ IS NULL + OR created_at < $6::TIMESTAMPTZ + ) + ) +SELECT + activity_log_combined_view.id, activity_log_combined_view.created_at, activity_log_combined_view.actor, activity_log_combined_view.action, activity_log_combined_view.resource_type, activity_log_combined_view.resource_name, activity_log_combined_view.team_slug, activity_log_combined_view.data, activity_log_combined_view.environment, + matching_entries.total_count +FROM + activity_log_combined_view + CROSS JOIN matching_entries +WHERE + activity_log_combined_view.team_slug = $1 AND ( $2::TEXT[] IS NULL - OR (resource_type || ':' || action) = ANY ($2::TEXT[]) + OR ( + activity_log_combined_view.resource_type || ':' || activity_log_combined_view.action + ) = ANY ($2::TEXT[]) ) AND ( $3::TEXT[] IS NULL - OR resource_type = ANY ($3::TEXT[]) + OR activity_log_combined_view.resource_type = ANY ($3::TEXT[]) ) AND ( $4::TEXT[] IS NULL - OR environment = ANY ($4::TEXT[]) + OR activity_log_combined_view.environment = ANY ($4::TEXT[]) ) AND ( $5::TIMESTAMPTZ IS NULL - OR created_at >= $5::TIMESTAMPTZ + OR activity_log_combined_view.created_at >= $5::TIMESTAMPTZ ) AND ( $6::TIMESTAMPTZ IS NULL - OR created_at < $6::TIMESTAMPTZ + OR activity_log_combined_view.created_at < $6::TIMESTAMPTZ ) ORDER BY - created_at DESC + activity_log_combined_view.created_at DESC LIMIT $8 OFFSET @@ -645,11 +1306,40 @@ func (q *Queries) ListForTeam(ctx context.Context, arg ListForTeamParams) ([]*Li } const listForTenant = `-- name: ListForTenant :many +WITH + matching_entries AS ( + SELECT + COUNT(*) AS total_count + FROM + activity_log_combined_view + WHERE + ( + $1::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY ($1::TEXT[]) + ) + AND ( + $2::TEXT[] IS NULL + OR resource_type = ANY ($2::TEXT[]) + ) + AND ( + $3::TEXT[] IS NULL + OR environment = ANY ($3::TEXT[]) + ) + AND ( + $4::TIMESTAMPTZ IS NULL + OR created_at >= $4::TIMESTAMPTZ + ) + AND ( + $5::TIMESTAMPTZ IS NULL + OR created_at < $5::TIMESTAMPTZ + ) + ) SELECT activity_log_combined_view.id, activity_log_combined_view.created_at, activity_log_combined_view.actor, activity_log_combined_view.action, activity_log_combined_view.resource_type, activity_log_combined_view.resource_name, activity_log_combined_view.team_slug, activity_log_combined_view.data, activity_log_combined_view.environment, - COUNT(*) OVER () AS total_count + matching_entries.total_count FROM activity_log_combined_view + CROSS JOIN matching_entries WHERE ( $1::TEXT[] IS NULL diff --git a/internal/activitylog/activitylogsql/querier.go b/internal/activitylog/activitylogsql/querier.go index afa51b188..18527dde1 100644 --- a/internal/activitylog/activitylogsql/querier.go +++ b/internal/activitylog/activitylogsql/querier.go @@ -10,14 +10,18 @@ import ( type Querier interface { Create(ctx context.Context, arg CreateParams) error - FacetsForActivityTypes(ctx context.Context, arg FacetsForActivityTypesParams) ([]*FacetsForActivityTypesRow, error) + FacetsForResource(ctx context.Context, arg FacetsForResourceParams) ([]*FacetsForResourceRow, error) + FacetsForResourceAndTeam(ctx context.Context, arg FacetsForResourceAndTeamParams) ([]*FacetsForResourceAndTeamRow, error) + FacetsForResourceTeamAndEnvironment(ctx context.Context, arg FacetsForResourceTeamAndEnvironmentParams) ([]*FacetsForResourceTeamAndEnvironmentRow, error) + FacetsForResourceWithoutTeam(ctx context.Context, arg FacetsForResourceWithoutTeamParams) ([]*FacetsForResourceWithoutTeamRow, error) + FacetsForTeam(ctx context.Context, arg FacetsForTeamParams) ([]*FacetsForTeamRow, error) + FacetsForTenantActivityTypes(ctx context.Context, arg FacetsForTenantActivityTypesParams) ([]*FacetsForTenantActivityTypesRow, error) Get(ctx context.Context, id uuid.UUID) (*ActivityLogCombinedView, error) ListByIDs(ctx context.Context, ids []uuid.UUID) ([]*ActivityLogCombinedView, error) ListForResource(ctx context.Context, arg ListForResourceParams) ([]*ListForResourceRow, error) - // A NULL team_slug matches tenant-wide resources only, not every team, mirroring the NULLS NOT DISTINCT - // index on service_accounts (name, team_slug). ListForResourceAndTeam(ctx context.Context, arg ListForResourceAndTeamParams) ([]*ListForResourceAndTeamRow, error) ListForResourceTeamAndEnvironment(ctx context.Context, arg ListForResourceTeamAndEnvironmentParams) ([]*ListForResourceTeamAndEnvironmentRow, error) + ListForResourceWithoutTeam(ctx context.Context, arg ListForResourceWithoutTeamParams) ([]*ListForResourceWithoutTeamRow, error) ListForTeam(ctx context.Context, arg ListForTeamParams) ([]*ListForTeamRow, error) ListForTenant(ctx context.Context, arg ListForTenantParams) ([]*ListForTenantRow, error) RefreshMaterializedView(ctx context.Context) error diff --git a/internal/activitylog/facets.go b/internal/activitylog/facets.go index ada79e148..1201c1960 100644 --- a/internal/activitylog/facets.go +++ b/internal/activitylog/facets.go @@ -2,6 +2,7 @@ package activitylog import ( "context" + "fmt" "slices" "strings" @@ -11,67 +12,200 @@ import ( func ComputeFacets(ctx context.Context, scope *ActivityLogScope, filter *ActivityLogFilter) (*ActivityLogFacets, error) { q := db(ctx) + filterValues := withFilters(filter) + resourceTypes := withResourceTypes(filter) + environments := withEnvironments(filter) + from := withFrom(filter) + to := withTo(filter) - activityTypeRows, err := q.FacetsForActivityTypes(ctx, activitylogsql.FacetsForActivityTypesParams{ - TeamSlug: scopeField(scope, func(s *ActivityLogScope) *string { return (*string)(s.TeamSlug) }), - MatchNullTeam: scope != nil && scope.MatchNullTeam, - ResourceType: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceType }), - ResourceName: scopeField(scope, func(s *ActivityLogScope) *string { return s.ResourceName }), - EnvironmentName: scopeField(scope, func(s *ActivityLogScope) *string { return s.EnvironmentName }), - From: withFrom(filter), - To: withTo(filter), - Filter: withFilters(filter), - FilterResourceTypes: withResourceTypes(filter), - FilterEnvironments: withEnvironments(filter), - FilterFrom: withFrom(filter), - FilterTo: withTo(filter), - }) - if err != nil { - return nil, err - } + if scope != nil && scope.TenantWide { + rows, err := q.FacetsForTenantActivityTypes(ctx, activitylogsql.FacetsForTenantActivityTypesParams{ + From: from, + To: to, + Filter: filterValues, + FilterResourceTypes: resourceTypes, + FilterEnvironments: environments, + FilterFrom: from, + FilterTo: to, + }) + if err != nil { + return nil, err + } - return buildFacets(activityTypeRows), nil -} + return buildFacets(rows, func(row *activitylogsql.FacetsForTenantActivityTypesRow) facetValues { + return facetValues{ + resourceType: row.ResourceType, + action: row.Action, + environment: row.Environment, + filteredCount: row.FilteredCount, + } + }), nil + } -func scopeField(scope *ActivityLogScope, fn func(*ActivityLogScope) *string) *string { if scope == nil { - return nil + return nil, fmt.Errorf("activity log facet scope is required") + } + + switch { + case scope.EnvironmentName != nil && scope.TeamSlug != nil && scope.ResourceType != nil && scope.ResourceName != nil: + rows, err := q.FacetsForResourceTeamAndEnvironment(ctx, activitylogsql.FacetsForResourceTeamAndEnvironmentParams{ + Filter: filterValues, + FilterResourceTypes: resourceTypes, + FilterEnvironments: environments, + FilterFrom: from, + FilterTo: to, + ResourceType: *scope.ResourceType, + ResourceName: *scope.ResourceName, + TeamSlug: scope.TeamSlug, + EnvironmentName: scope.EnvironmentName, + From: from, + To: to, + }) + if err != nil { + return nil, err + } + + return buildFacets(rows, facetValuesForResourceTeamAndEnvironment), nil + + case scope.MatchNullTeam && scope.ResourceType != nil && scope.ResourceName != nil: + if scope.TeamSlug == nil { + rows, err := q.FacetsForResourceWithoutTeam(ctx, activitylogsql.FacetsForResourceWithoutTeamParams{ + Filter: filterValues, + FilterResourceTypes: resourceTypes, + FilterEnvironments: environments, + FilterFrom: from, + FilterTo: to, + ResourceType: *scope.ResourceType, + ResourceName: *scope.ResourceName, + From: from, + To: to, + }) + if err != nil { + return nil, err + } + + return buildFacets(rows, facetValuesForResourceWithoutTeam), nil + } + + rows, err := q.FacetsForResourceAndTeam(ctx, activitylogsql.FacetsForResourceAndTeamParams{ + Filter: filterValues, + FilterResourceTypes: resourceTypes, + FilterEnvironments: environments, + FilterFrom: from, + FilterTo: to, + ResourceType: *scope.ResourceType, + ResourceName: *scope.ResourceName, + TeamSlug: scope.TeamSlug, + From: from, + To: to, + }) + if err != nil { + return nil, err + } + + return buildFacets(rows, facetValuesForResourceAndTeam), nil + + case scope.ResourceType != nil && scope.ResourceName != nil: + rows, err := q.FacetsForResource(ctx, activitylogsql.FacetsForResourceParams{ + Filter: filterValues, + FilterResourceTypes: resourceTypes, + FilterEnvironments: environments, + FilterFrom: from, + FilterTo: to, + ResourceType: *scope.ResourceType, + ResourceName: *scope.ResourceName, + From: from, + To: to, + }) + if err != nil { + return nil, err + } + + return buildFacets(rows, facetValuesForResource), nil + + case scope.TeamSlug != nil: + rows, err := q.FacetsForTeam(ctx, activitylogsql.FacetsForTeamParams{ + Filter: filterValues, + FilterResourceTypes: resourceTypes, + FilterEnvironments: environments, + FilterFrom: from, + FilterTo: to, + TeamSlug: scope.TeamSlug, + From: from, + To: to, + }) + if err != nil { + return nil, err + } + + return buildFacets(rows, facetValuesForTeam), nil + + default: + return nil, fmt.Errorf("unsupported activity log facet scope") } - return fn(scope) } -func buildFacets(activityTypeRows []*activitylogsql.FacetsForActivityTypesRow) *ActivityLogFacets { +func facetValuesForTeam(row *activitylogsql.FacetsForTeamRow) facetValues { + return facetValues{resourceType: row.ResourceType, action: row.Action, environment: row.Environment, filteredCount: row.FilteredCount} +} + +func facetValuesForResource(row *activitylogsql.FacetsForResourceRow) facetValues { + return facetValues{resourceType: row.ResourceType, action: row.Action, environment: row.Environment, filteredCount: row.FilteredCount} +} + +func facetValuesForResourceAndTeam(row *activitylogsql.FacetsForResourceAndTeamRow) facetValues { + return facetValues{resourceType: row.ResourceType, action: row.Action, environment: row.Environment, filteredCount: row.FilteredCount} +} + +func facetValuesForResourceWithoutTeam(row *activitylogsql.FacetsForResourceWithoutTeamRow) facetValues { + return facetValues{resourceType: row.ResourceType, action: row.Action, environment: row.Environment, filteredCount: row.FilteredCount} +} + +func facetValuesForResourceTeamAndEnvironment(row *activitylogsql.FacetsForResourceTeamAndEnvironmentRow) facetValues { + return facetValues{resourceType: row.ResourceType, action: row.Action, environment: row.Environment, filteredCount: row.FilteredCount} +} + +type facetValues struct { + resourceType string + action string + environment string + filteredCount int64 +} + +func buildFacets[T any](activityTypeRows []*T, values func(*T) facetValues) *ActivityLogFacets { activityTypeCounts := map[ActivityLogActivityType]int{} resourceTypeCounts := map[ActivityLogEntryResourceType]int{} environmentCounts := map[string]int{} for _, row := range activityTypeRows { + row := values(row) + // Seed with 0 to ensure all values that exist in this scope are present - rt := ActivityLogEntryResourceType(row.ResourceType) + rt := ActivityLogEntryResourceType(row.resourceType) if _, ok := resourceTypeCounts[rt]; !ok { resourceTypeCounts[rt] = 0 } - if row.Environment != "" { - if _, ok := environmentCounts[row.Environment]; !ok { - environmentCounts[row.Environment] = 0 + if row.environment != "" { + if _, ok := environmentCounts[row.environment]; !ok { + environmentCounts[row.environment] = 0 } } - for _, at := range LookupActivityTypes(row.ResourceType, row.Action) { + for _, at := range LookupActivityTypes(row.resourceType, row.action) { if _, ok := activityTypeCounts[at]; !ok { activityTypeCounts[at] = 0 } } - filteredCount := int(row.FilteredCount) + filteredCount := int(row.filteredCount) resourceTypeCounts[rt] += filteredCount - if row.Environment != "" { - environmentCounts[row.Environment] += filteredCount + if row.environment != "" { + environmentCounts[row.environment] += filteredCount } - for _, at := range LookupActivityTypes(row.ResourceType, row.Action) { + for _, at := range LookupActivityTypes(row.resourceType, row.action) { activityTypeCounts[at] += filteredCount } } diff --git a/internal/activitylog/model.go b/internal/activitylog/model.go index f60674a4a..516d905ec 100644 --- a/internal/activitylog/model.go +++ b/internal/activitylog/model.go @@ -50,6 +50,7 @@ type ActivityLogScope struct { ResourceType *string ResourceName *string EnvironmentName *string + TenantWide bool // MatchNullTeam reads a nil TeamSlug as "no team" rather than "any team". Required where a resource // name is only unique within a team. diff --git a/internal/activitylog/queries.go b/internal/activitylog/queries.go index dee6fab30..f1b0312d1 100644 --- a/internal/activitylog/queries.go +++ b/internal/activitylog/queries.go @@ -142,7 +142,7 @@ func ListForTenant(ctx context.Context, page *pagination.Pagination, filter *Act return &ActivityLogEntryConnection{ Connection: *conn, - scope: &ActivityLogScope{}, + scope: &ActivityLogScope{TenantWide: true}, filter: filter, }, nil } @@ -189,10 +189,49 @@ func ListForResource(ctx context.Context, resourceType ActivityLogEntryResourceT func ListForResourceAndTeam(ctx context.Context, resourceType ActivityLogEntryResourceType, teamSlug *slug.Slug, resourceName string, page *pagination.Pagination, filter *ActivityLogFilter) (*ActivityLogEntryConnection, error) { q := db(ctx) + if teamSlug == nil { + ret, err := q.ListForResourceWithoutTeam(ctx, activitylogsql.ListForResourceWithoutTeamParams{ + ResourceType: string(resourceType), + ResourceName: resourceName, + Offset: page.Offset(), + Limit: page.Limit(), + Filter: withFilters(filter), + ResourceTypes: withResourceTypes(filter), + Environments: withEnvironments(filter), + From: withFrom(filter), + To: withTo(filter), + }) + if err != nil { + return nil, err + } + + var total int64 + if len(ret) > 0 { + total = ret[0].TotalCount + } + + conn, err := pagination.NewConvertConnectionWithError(ret, page, total, func(from *activitylogsql.ListForResourceWithoutTeamRow) (ActivityLogEntry, error) { + return toGraphActivityLogEntry(&from.ActivityLogCombinedView) + }) + if err != nil { + return nil, err + } + + return &ActivityLogEntryConnection{ + Connection: *conn, + scope: &ActivityLogScope{ + ResourceType: new(string(resourceType)), + ResourceName: &resourceName, + MatchNullTeam: true, + }, + filter: filter, + }, nil + } + ret, err := q.ListForResourceAndTeam(ctx, activitylogsql.ListForResourceAndTeamParams{ ResourceType: string(resourceType), ResourceName: resourceName, - TeamSlug: (*string)(teamSlug), + TeamSlug: teamSlug, Offset: page.Offset(), Limit: page.Limit(), Filter: withFilters(filter), diff --git a/internal/activitylog/queries/activitylog.sql b/internal/activitylog/queries/activitylog.sql index 18b10d1e9..cb1f34463 100644 --- a/internal/activitylog/queries/activitylog.sql +++ b/internal/activitylog/queries/activitylog.sql @@ -1,33 +1,65 @@ -- name: ListForTeam :many +WITH + matching_entries AS ( + SELECT + COUNT(*) AS total_count + FROM + activity_log_combined_view + WHERE + team_slug = @team_slug + AND ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('environments')::TEXT[]) + ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) + ) SELECT sqlc.embed(activity_log_combined_view), - COUNT(*) OVER () AS total_count + matching_entries.total_count FROM activity_log_combined_view + CROSS JOIN matching_entries WHERE - team_slug = @team_slug + activity_log_combined_view.team_slug = @team_slug AND ( sqlc.narg('filter')::TEXT[] IS NULL - OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + OR ( + activity_log_combined_view.resource_type || ':' || activity_log_combined_view.action + ) = ANY (sqlc.narg('filter')::TEXT[]) ) AND ( sqlc.narg('resource_types')::TEXT[] IS NULL - OR resource_type = ANY (sqlc.narg('resource_types')::TEXT[]) + OR activity_log_combined_view.resource_type = ANY (sqlc.narg('resource_types')::TEXT[]) ) AND ( sqlc.narg('environments')::TEXT[] IS NULL - OR environment = ANY (sqlc.narg('environments')::TEXT[]) + OR activity_log_combined_view.environment = ANY (sqlc.narg('environments')::TEXT[]) ) AND ( sqlc.narg('from')::TIMESTAMPTZ IS NULL - OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + OR activity_log_combined_view.created_at >= sqlc.narg('from')::TIMESTAMPTZ ) AND ( sqlc.narg('to')::TIMESTAMPTZ IS NULL - OR created_at < sqlc.narg('to')::TIMESTAMPTZ + OR activity_log_combined_view.created_at < sqlc.narg('to')::TIMESTAMPTZ ) ORDER BY - created_at DESC + activity_log_combined_view.created_at DESC LIMIT sqlc.arg('limit') OFFSET @@ -35,11 +67,40 @@ OFFSET ; -- name: ListForTenant :many +WITH + matching_entries AS ( + SELECT + COUNT(*) AS total_count + FROM + activity_log_combined_view + WHERE + ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('environments')::TEXT[]) + ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) + ) SELECT sqlc.embed(activity_log_combined_view), - COUNT(*) OVER () AS total_count + matching_entries.total_count FROM activity_log_combined_view + CROSS JOIN matching_entries WHERE ( sqlc.narg('filter')::TEXT[] IS NULL @@ -70,6 +131,76 @@ OFFSET ; -- name: ListForResource :many +WITH + matching_entries AS ( + SELECT + COUNT(*) AS total_count + FROM + activity_log_combined_view + WHERE + resource_type = @resource_type + AND resource_name = @resource_name + AND ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('environments')::TEXT[]) + ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) + ) +SELECT + sqlc.embed(activity_log_combined_view), + matching_entries.total_count +FROM + activity_log_combined_view + CROSS JOIN matching_entries +WHERE + activity_log_combined_view.resource_type = @resource_type + AND activity_log_combined_view.resource_name = @resource_name + AND ( + sqlc.narg('filter')::TEXT[] IS NULL + OR ( + activity_log_combined_view.resource_type || ':' || activity_log_combined_view.action + ) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('resource_types')::TEXT[] IS NULL + OR activity_log_combined_view.resource_type = ANY (sqlc.narg('resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('environments')::TEXT[] IS NULL + OR activity_log_combined_view.environment = ANY (sqlc.narg('environments')::TEXT[]) + ) + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR activity_log_combined_view.created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR activity_log_combined_view.created_at < sqlc.narg('to')::TIMESTAMPTZ + ) +ORDER BY + activity_log_combined_view.created_at DESC +LIMIT + sqlc.arg('limit') +OFFSET + sqlc.arg('offset') +; + +-- name: ListForResourceAndTeam :many SELECT sqlc.embed(activity_log_combined_view), COUNT(*) OVER () AS total_count @@ -78,6 +209,7 @@ FROM WHERE resource_type = @resource_type AND resource_name = @resource_name + AND team_slug = @team_slug AND ( sqlc.narg('filter')::TEXT[] IS NULL OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) @@ -106,9 +238,7 @@ OFFSET sqlc.arg('offset') ; --- A NULL team_slug matches tenant-wide resources only, not every team, mirroring the NULLS NOT DISTINCT --- index on service_accounts (name, team_slug). --- name: ListForResourceAndTeam :many +-- name: ListForResourceWithoutTeam :many SELECT sqlc.embed(activity_log_combined_view), COUNT(*) OVER () AS total_count @@ -117,7 +247,7 @@ FROM WHERE resource_type = @resource_type AND resource_name = @resource_name - AND team_slug IS NOT DISTINCT FROM sqlc.narg('team_slug')::TEXT + AND team_slug IS NULL AND ( sqlc.narg('filter')::TEXT[] IS NULL OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) @@ -228,12 +358,11 @@ ORDER BY created_at DESC ; --- name: FacetsForActivityTypes :many +-- name: FacetsForTeam :many SELECT resource_type, action, COALESCE(environment, '') AS environment, - COUNT(*) AS total_count, COUNT(*) FILTER ( WHERE ( @@ -260,27 +389,266 @@ SELECT FROM activity_log_combined_view WHERE - ( - CASE - -- match_null_team keeps facet counts consistent with ListForResourceAndTeam. - WHEN sqlc.arg('match_null_team')::BOOLEAN THEN team_slug IS NOT DISTINCT FROM sqlc.narg('team_slug')::TEXT - WHEN sqlc.narg('team_slug')::TEXT IS NULL THEN TRUE - ELSE team_slug = sqlc.narg('team_slug') - END + team_slug = @team_slug + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ ) AND ( - sqlc.narg('resource_type')::TEXT IS NULL - OR resource_type = sqlc.narg('resource_type') + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ ) +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +; + +-- name: FacetsForResource :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) FILTER ( + WHERE + ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('filter_resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('filter_resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('filter_environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('filter_environments')::TEXT[]) + ) + AND ( + sqlc.narg('filter_from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('filter_from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('filter_to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('filter_to')::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + resource_type = @resource_type + AND resource_name = @resource_name AND ( - sqlc.narg('resource_name')::TEXT IS NULL - OR resource_name = sqlc.narg('resource_name') + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ ) AND ( - sqlc.narg('environment_name')::TEXT IS NULL - OR environment = sqlc.narg('environment_name') + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +; + +-- name: FacetsForResourceAndTeam :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) FILTER ( + WHERE + ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('filter_resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('filter_resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('filter_environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('filter_environments')::TEXT[]) + ) + AND ( + sqlc.narg('filter_from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('filter_from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('filter_to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('filter_to')::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + resource_type = @resource_type + AND resource_name = @resource_name + AND team_slug = @team_slug + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +; + +-- name: FacetsForResourceWithoutTeam :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) FILTER ( + WHERE + ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('filter_resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('filter_resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('filter_environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('filter_environments')::TEXT[]) + ) + AND ( + sqlc.narg('filter_from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('filter_from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('filter_to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('filter_to')::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + resource_type = @resource_type + AND resource_name = @resource_name + AND team_slug IS NULL + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +; + +-- name: FacetsForResourceTeamAndEnvironment :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) FILTER ( + WHERE + ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('filter_resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('filter_resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('filter_environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('filter_environments')::TEXT[]) + ) + AND ( + sqlc.narg('filter_from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('filter_from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('filter_to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('filter_to')::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + resource_type = @resource_type + AND resource_name = @resource_name + AND team_slug = @team_slug + AND environment = @environment_name + AND ( + sqlc.narg('from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('from')::TIMESTAMPTZ ) AND ( + sqlc.narg('to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('to')::TIMESTAMPTZ + ) +GROUP BY + resource_type, + action, + environment +ORDER BY + resource_type, + action, + environment +; + +-- name: FacetsForTenantActivityTypes :many +SELECT + resource_type, + action, + COALESCE(environment, '') AS environment, + COUNT(*) AS total_count, + COUNT(*) FILTER ( + WHERE + ( + sqlc.narg('filter')::TEXT[] IS NULL + OR (resource_type || ':' || action) = ANY (sqlc.narg('filter')::TEXT[]) + ) + AND ( + sqlc.narg('filter_resource_types')::TEXT[] IS NULL + OR resource_type = ANY (sqlc.narg('filter_resource_types')::TEXT[]) + ) + AND ( + sqlc.narg('filter_environments')::TEXT[] IS NULL + OR environment = ANY (sqlc.narg('filter_environments')::TEXT[]) + ) + AND ( + sqlc.narg('filter_from')::TIMESTAMPTZ IS NULL + OR created_at >= sqlc.narg('filter_from')::TIMESTAMPTZ + ) + AND ( + sqlc.narg('filter_to')::TIMESTAMPTZ IS NULL + OR created_at < sqlc.narg('filter_to')::TIMESTAMPTZ + ) + ) AS filtered_count +FROM + activity_log_combined_view +WHERE + ( sqlc.narg('from')::TIMESTAMPTZ IS NULL OR created_at >= sqlc.narg('from')::TIMESTAMPTZ ) diff --git a/internal/database/migrations/0074_activity_log_tenant_indexes.sql b/internal/database/migrations/0074_activity_log_tenant_indexes.sql new file mode 100644 index 000000000..78df04dea --- /dev/null +++ b/internal/database/migrations/0074_activity_log_tenant_indexes.sql @@ -0,0 +1,50 @@ +-- +goose NO TRANSACTION +-- +goose Up +DROP INDEX CONCURRENTLY IF EXISTS activity_log_entries_team_scope_idx +; + +CREATE INDEX CONCURRENTLY activity_log_entries_team_scope_idx ON activity_log_entries (team_slug, created_at DESC) INCLUDE (resource_type, action, environment) +; + +DROP INDEX CONCURRENTLY IF EXISTS activity_log_subset_mat_view_team_scope_idx +; + +CREATE INDEX CONCURRENTLY activity_log_subset_mat_view_team_scope_idx ON activity_log_subset_mat_view (team_slug, created_at DESC) INCLUDE (resource_type, action, environment) +; + +DROP INDEX CONCURRENTLY IF EXISTS activity_log_entries_resource_scope_idx +; + +CREATE INDEX CONCURRENTLY activity_log_entries_resource_scope_idx ON activity_log_entries (resource_type, resource_name, created_at DESC) INCLUDE (team_slug, environment, action) +; + +DROP INDEX CONCURRENTLY IF EXISTS activity_log_subset_mat_view_resource_scope_idx +; + +CREATE INDEX CONCURRENTLY activity_log_subset_mat_view_resource_scope_idx ON activity_log_subset_mat_view (resource_type, resource_name, created_at DESC) INCLUDE (team_slug, environment, action) +; + +DROP INDEX CONCURRENTLY IF EXISTS activity_log_entries_team_slug_created_at_idx +; + +DROP INDEX CONCURRENTLY IF EXISTS activity_log_subset_mat_view_team_created_at_idx +; + +-- +goose Down +CREATE INDEX CONCURRENTLY IF NOT EXISTS activity_log_entries_team_slug_created_at_idx ON activity_log_entries (team_slug, created_at DESC) +; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS activity_log_subset_mat_view_team_created_at_idx ON activity_log_subset_mat_view (team_slug, created_at DESC) +; + +DROP INDEX CONCURRENTLY IF EXISTS activity_log_entries_team_scope_idx +; + +DROP INDEX CONCURRENTLY IF EXISTS activity_log_subset_mat_view_team_scope_idx +; + +DROP INDEX CONCURRENTLY IF EXISTS activity_log_entries_resource_scope_idx +; + +DROP INDEX CONCURRENTLY IF EXISTS activity_log_subset_mat_view_resource_scope_idx +;