From 9026dd12c7fa704f8ed50474a2d89b560cfbc791 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 26 Aug 2026 13:50:36 +0000 Subject: [PATCH 1/2] feat(coderd/wsrelated): add include_related parser in its own package --- coderd/workspacebuilds.go | 13 +- coderd/workspaces.go | 15 +- coderd/wsrelated/parse.go | 214 ++++++++++++++++++++++++++ coderd/wsrelated/parse_test.go | 175 +++++++++++++++++++++ coderd/wsrelated/related.go | 85 ++++++++++ coderd/wsrelated/related_test.go | 42 +++++ coderd/wsrelateddata.go | 87 ----------- coderd/wsrelateddata_internal_test.go | 75 +++------ 8 files changed, 552 insertions(+), 154 deletions(-) create mode 100644 coderd/wsrelated/parse.go create mode 100644 coderd/wsrelated/parse_test.go create mode 100644 coderd/wsrelated/related.go create mode 100644 coderd/wsrelated/related_test.go delete mode 100644 coderd/wsrelateddata.go diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 81edda6dc7f..c167305b75a 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -35,6 +35,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/coderd/wspubsub" + "github.com/coder/coder/v2/coderd/wsrelated" "github.com/coder/coder/v2/codersdk" ) @@ -51,7 +52,7 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { workspaceBuild := httpmw.WorkspaceBuildParam(r) workspace := httpmw.WorkspaceParam(r) - data, err := api.workspaceBuildsData(ctx, []database.WorkspaceBuild{workspaceBuild}, allLatestBuildRelated()) + data, err := api.workspaceBuildsData(ctx, []database.WorkspaceBuild{workspaceBuild}, wsrelated.AllLatestBuild()) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error getting workspace build data.", @@ -190,7 +191,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - data, err := api.workspaceBuildsData(ctx, workspaceBuilds, allLatestBuildRelated()) + data, err := api.workspaceBuildsData(ctx, workspaceBuilds, wsrelated.AllLatestBuild()) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error getting workspace build data.", @@ -281,7 +282,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ return } - data, err := api.workspaceBuildsData(ctx, []database.WorkspaceBuild{workspaceBuild}, allLatestBuildRelated()) + data, err := api.workspaceBuildsData(ctx, []database.WorkspaceBuild{workspaceBuild}, wsrelated.AllLatestBuild()) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error getting workspace build data.", @@ -1117,7 +1118,7 @@ type workspaceBuildsData struct { // computed with window functions over pending jobs and provisioner daemons and // are comparatively expensive. Otherwise, it uses the cheaper // GetProvisionerJobsByIDs and leaves QueuePosition and QueueSize zero. -func (api *API) provisionerJobsByIDs(ctx context.Context, jobIDs []uuid.UUID, cfg jobRelated) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) { +func (api *API) provisionerJobsByIDs(ctx context.Context, jobIDs []uuid.UUID, cfg wsrelated.Job) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) { if cfg.QueuePosition { return api.Database.GetProvisionerJobsByIDsWithQueuePosition(ctx, database.GetProvisionerJobsByIDsWithQueuePositionParams{ IDs: jobIDs, @@ -1140,7 +1141,7 @@ func (api *API) provisionerJobsByIDs(ctx context.Context, jobIDs []uuid.UUID, cf return jobs, nil } -func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []database.WorkspaceBuild, cfg latestBuildRelated) (workspaceBuildsData, error) { +func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []database.WorkspaceBuild, cfg wsrelated.LatestBuild) (workspaceBuildsData, error) { jobIDs := make([]uuid.UUID, 0, len(workspaceBuilds)) for _, build := range workspaceBuilds { jobIDs = append(jobIDs, build.JobID) @@ -1290,7 +1291,7 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab } var statuses []database.WorkspaceAppStatus - if cfg.appStatuses() { + if cfg.AppStatuses() { appIDs := make([]uuid.UUID, 0) for _, app := range apps { appIDs = append(appIDs, app.ID) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c6bc0b3701c..d79b12403fe 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -42,6 +42,7 @@ import ( "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/coderd/wspubsub" + "github.com/coder/coder/v2/coderd/wsrelated" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/wsjson" @@ -96,7 +97,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { return } - data, err := api.workspaceData(ctx, []database.Workspace{workspace}, allWorkspaceRelated()) + data, err := api.workspaceData(ctx, []database.Workspace{workspace}, wsrelated.All()) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace resources.", @@ -224,7 +225,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { return } - data, err := api.workspaceData(ctx, workspaces, allWorkspaceRelated()) + data, err := api.workspaceData(ctx, workspaces, wsrelated.All()) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace resources.", @@ -313,7 +314,7 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) return } - data, err := api.workspaceData(ctx, []database.Workspace{workspace}, allWorkspaceRelated()) + data, err := api.workspaceData(ctx, []database.Workspace{workspace}, wsrelated.All()) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace resources.", @@ -1570,7 +1571,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { return } - data, err := api.workspaceData(ctx, []database.Workspace{workspace}, allWorkspaceRelated()) + data, err := api.workspaceData(ctx, []database.Workspace{workspace}, wsrelated.All()) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace resources.", @@ -2148,7 +2149,7 @@ func (api *API) watchWorkspace( return } - data, err := api.workspaceData(ctx, []database.Workspace{workspace}, allWorkspaceRelated()) + data, err := api.workspaceData(ctx, []database.Workspace{workspace}, wsrelated.All()) if err != nil { _ = sendEvent(codersdk.ServerSentEvent{ Type: codersdk.ServerSentEventTypeError, @@ -2687,7 +2688,7 @@ func (api *API) allowWorkspaceSharing(ctx context.Context, rw http.ResponseWrite // does not have the correct perms to read a given template, the template will // not be returned. // So the caller must check the templates & users exist before using them. -func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspace, cfg workspaceRelated) (workspaceData, error) { +func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspace, cfg wsrelated.Config) (workspaceData, error) { workspaceIDs := make([]uuid.UUID, 0, len(workspaces)) templateIDs := make([]uuid.UUID, 0, len(workspaces)) for _, workspace := range workspaces { @@ -2723,7 +2724,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa return nil }) } - if cfg.LatestBuild.appStatuses() { + if cfg.LatestBuild.AppStatuses() { eg.Go(func() (err error) { // This query must be run as system restricted to be efficient. // nolint:gocritic diff --git a/coderd/wsrelated/parse.go b/coderd/wsrelated/parse.go new file mode 100644 index 00000000000..9c75bba54f6 --- /dev/null +++ b/coderd/wsrelated/parse.go @@ -0,0 +1,214 @@ +package wsrelated + +import ( + "strings" + + "golang.org/x/xerrors" +) + +// schema holds the root nodes of the include_related hierarchy. It is built once +// in init rather than on every Parse call, since it is immutable. +var schema []relatedNode + +func init() { + schema = buildSchema() +} + +// relatedNode describes one node in the related-data hierarchy for the purpose +// of parsing the include_related query parameter. selectInto marks this node as +// selected in the target tree; via the ensure* helpers it also creates the +// node's ancestors, so selecting a node implicitly selects its ancestors. +type relatedNode struct { + name string + selectInto func(*Config) + children []relatedNode +} + +func (c *Config) ensureLatestBuild() *LatestBuild { + if c.LatestBuild == nil { + c.LatestBuild = &LatestBuild{} + } + return c.LatestBuild +} + +func (c *LatestBuild) ensureJob() *Job { + if c.Job == nil { + c.Job = &Job{} + } + return c.Job +} + +func (c *LatestBuild) ensureResources() *Resources { + if c.Resources == nil { + c.Resources = &Resources{} + } + return c.Resources +} + +func (c *Resources) ensureAgents() *Agents { + if c.Agents == nil { + c.Agents = &Agents{} + } + return c.Agents +} + +func (c *Agents) ensureApps() *Apps { + if c.Apps == nil { + c.Apps = &Apps{} + } + return c.Apps +} + +// buildSchema constructs the include_related hierarchy. Each node's name matches +// a segment in a dotted path, and its selectInto closure mirrors the +// corresponding node in Config. +func buildSchema() []relatedNode { + return []relatedNode{ + { + name: "template", + selectInto: func(r *Config) { r.Template = true }, + }, + { + name: "latest_build", + selectInto: func(r *Config) { r.ensureLatestBuild() }, + children: []relatedNode{ + { + name: "job", + selectInto: func(r *Config) { r.ensureLatestBuild().ensureJob() }, + children: []relatedNode{ + { + name: "queue_position", + selectInto: func(r *Config) { r.ensureLatestBuild().ensureJob().QueuePosition = true }, + }, + }, + }, + { + name: "resources", + selectInto: func(r *Config) { r.ensureLatestBuild().ensureResources() }, + children: []relatedNode{ + { + name: "metadata", + selectInto: func(r *Config) { r.ensureLatestBuild().ensureResources().Metadata = true }, + }, + { + name: "agents", + selectInto: func(r *Config) { r.ensureLatestBuild().ensureResources().ensureAgents() }, + children: []relatedNode{ + { + name: "apps", + selectInto: func(r *Config) { r.ensureLatestBuild().ensureResources().ensureAgents().ensureApps() }, + children: []relatedNode{ + { + name: "statuses", + selectInto: func(r *Config) { r.ensureLatestBuild().ensureResources().ensureAgents().ensureApps().Statuses = true }, + }, + }, + }, + { + name: "scripts", + selectInto: func(r *Config) { r.ensureLatestBuild().ensureResources().ensureAgents().Scripts = true }, + }, + { + name: "log_sources", + selectInto: func(r *Config) { r.ensureLatestBuild().ensureResources().ensureAgents().LogSources = true }, + }, + }, + }, + }, + }, + { + name: "template_version", + selectInto: func(r *Config) { r.ensureLatestBuild().TemplateVersion = true }, + }, + }, + }, + } +} + +// Parse parses the include_related query parameter: a comma-separated list of +// dotted hierarchy paths, e.g. "template,latest_build.resources.agents.*". Each +// path may end in a single wildcard "*" that selects the node and all of its +// descendants; "*" on its own selects everything. Selecting a node implicitly +// selects its ancestors. +// +// Surrounding whitespace and empty entries are ignored. An empty string selects +// nothing but the workspace itself; callers that want the RFC's "absent +// parameter means everything" behavior should use All when the parameter is not +// present. An unknown path or a wildcard that is not the final segment returns +// an error. +func Parse(includeRelated string) (Config, error) { + var result Config + + tokens := strings.Split(includeRelated, ",") + for i, raw := range tokens { + token := strings.TrimSpace(raw) + if token == "" { + // Reject empty entries such as "a,,b" or ", ,", but allow a single + // optional trailing comma (an empty final element). + if i == len(tokens)-1 { + continue + } + return Config{}, xerrors.New("empty include_related path") + } + + if token == "*" { + for _, n := range schema { + selectSubtree(n, &result) + } + continue + } + + path := token + wildcard := false + if strings.HasSuffix(path, ".*") { + wildcard = true + path = strings.TrimSuffix(path, ".*") + } + + segments := strings.Split(path, ".") + for _, s := range segments { + if s == "" || s == "*" { + return Config{}, xerrors.Errorf("invalid include_related path %q", token) + } + } + + node, ok := findRelatedNode(schema, segments) + if !ok { + return Config{}, xerrors.Errorf("unknown include_related path %q", token) + } + + if wildcard { + selectSubtree(node, &result) + } else { + node.selectInto(&result) + } + } + + return result, nil +} + +// findRelatedNode walks the schema following the dotted path segments, returning +// the matching node. +func findRelatedNode(nodes []relatedNode, segments []string) (relatedNode, bool) { + if len(segments) == 0 { + return relatedNode{}, false + } + for _, n := range nodes { + if n.name != segments[0] { + continue + } + if len(segments) == 1 { + return n, true + } + return findRelatedNode(n.children, segments[1:]) + } + return relatedNode{}, false +} + +// selectSubtree selects a node and all of its descendants. +func selectSubtree(n relatedNode, r *Config) { + n.selectInto(r) + for _, c := range n.children { + selectSubtree(c, r) + } +} diff --git a/coderd/wsrelated/parse_test.go b/coderd/wsrelated/parse_test.go new file mode 100644 index 00000000000..5a4596071c8 --- /dev/null +++ b/coderd/wsrelated/parse_test.go @@ -0,0 +1,175 @@ +package wsrelated_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/wsrelated" +) + +func TestParse(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + want wsrelated.Config + }{ + { + name: "Empty", + input: "", + want: wsrelated.Config{}, + }, + { + name: "Template", + input: "template", + want: wsrelated.Config{Template: true}, + }, + { + name: "LatestBuild", + input: "latest_build", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{}}, + }, + { + name: "JobSelectsAncestor", + input: "latest_build.job", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{Job: &wsrelated.Job{}}}, + }, + { + name: "QueuePositionSelectsAncestors", + input: "latest_build.job.queue_position", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{Job: &wsrelated.Job{QueuePosition: true}}}, + }, + { + name: "TemplateVersion", + input: "latest_build.template_version", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{TemplateVersion: true}}, + }, + { + name: "Metadata", + input: "latest_build.resources.metadata", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{ + Resources: &wsrelated.Resources{Metadata: true}, + }}, + }, + { + name: "AppStatusesDeep", + input: "latest_build.resources.agents.apps.statuses", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{ + Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{Apps: &wsrelated.Apps{Statuses: true}}}, + }}, + }, + { + name: "WildcardJob", + input: "latest_build.job.*", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{Job: &wsrelated.Job{QueuePosition: true}}}, + }, + { + name: "WildcardAgents", + input: "latest_build.resources.agents.*", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{ + Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{ + Apps: &wsrelated.Apps{Statuses: true}, + Scripts: true, + LogSources: true, + }}, + }}, + }, + { + name: "WildcardLatestBuild", + input: "latest_build.*", + want: wsrelated.Config{LatestBuild: new(wsrelated.AllLatestBuild())}, + }, + { + name: "WildcardOnLeaf", + input: "template.*", + want: wsrelated.Config{Template: true}, + }, + { + name: "RootWildcard", + input: "*", + want: wsrelated.All(), + }, + { + name: "MultiplePaths", + input: "template,latest_build.template_version", + want: wsrelated.Config{ + Template: true, + LatestBuild: &wsrelated.LatestBuild{TemplateVersion: true}, + }, + }, + { + name: "MergedSiblings", + input: "latest_build.job,latest_build.resources.metadata", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{ + Job: &wsrelated.Job{}, + Resources: &wsrelated.Resources{Metadata: true}, + }}, + }, + { + name: "OverlappingAncestorAndChild", + input: "latest_build,latest_build.job", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{Job: &wsrelated.Job{}}}, + }, + { + name: "WhitespaceAndTrailingComma", + input: " template , latest_build , ", + want: wsrelated.Config{ + Template: true, + LatestBuild: &wsrelated.LatestBuild{}, + }, + }, + { + name: "WildcardSubtreeCoversExplicitChild", + input: "latest_build.resources.*,latest_build.resources.agents.scripts", + want: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{ + Resources: &wsrelated.Resources{ + Metadata: true, + Agents: &wsrelated.Agents{ + Apps: &wsrelated.Apps{Statuses: true}, + Scripts: true, + LogSources: true, + }, + }, + }}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := wsrelated.Parse(tc.input) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestParseErrors(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + }{ + {name: "UnknownTopLevel", input: "bogus"}, + {name: "DoubleComma", input: "template,,template"}, + {name: "LeadingComma", input: ",template"}, + {name: "WhitespaceOnlyEntries", input: ", ,"}, + {name: "UnknownChild", input: "latest_build.bogus"}, + {name: "PathBeyondLeaf", input: "latest_build.job.queue_position.bogus"}, + {name: "MidWildcard", input: "latest_build.*.agents"}, + {name: "EmptySegment", input: "latest_build..job"}, + {name: "LeadingWildcardSegment", input: "*.template"}, + {name: "UnknownWithValidSibling", input: "template,bogus"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := wsrelated.Parse(tc.input) + require.Error(t, err) + }) + } +} diff --git a/coderd/wsrelated/related.go b/coderd/wsrelated/related.go new file mode 100644 index 00000000000..1ffa7b1351b --- /dev/null +++ b/coderd/wsrelated/related.go @@ -0,0 +1,85 @@ +// Package wsrelated models which workspace-related database objects to load when +// building a codersdk.Workspace, and parses the include_related query parameter +// into that model. Loading a fully populated workspace is expensive, so callers +// use a selection to avoid querying data they will not use. +// +// The selection is a tree that mirrors the parent/child relationships between +// those objects: a build has a job, resources, and a template version; a +// resource has agents; an agent has apps; and so on. Branch nodes are pointers +// that are non-nil when selected; leaf nodes are bools. Modeling it as a tree +// makes selecting a child without its parent unrepresentable, which is exactly +// the constraint loading requires: a parent must be queried to learn the +// identifiers of its children. +package wsrelated + +// Config is the root of the selection tree. A zero value (nil branches) selects +// nothing but the workspace itself. All selects everything. +// +// The trailing comment on each field is that node's dotted path from the root of +// the tree, e.g. latest_build.resources.agents. +type Config struct { + Template bool // template + LatestBuild *LatestBuild // latest_build +} + +type LatestBuild struct { + Job *Job // latest_build.job + Resources *Resources // latest_build.resources + TemplateVersion bool // latest_build.template_version +} + +type Job struct { + QueuePosition bool // latest_build.job.queue_position +} + +type Resources struct { + Metadata bool // latest_build.resources.metadata + Agents *Agents // latest_build.resources.agents +} + +type Agents struct { + Apps *Apps // latest_build.resources.agents.apps + Scripts bool // latest_build.resources.agents.scripts + LogSources bool // latest_build.resources.agents.log_sources +} + +type Apps struct { + Statuses bool // latest_build.resources.agents.apps.statuses +} + +// All returns a selection that loads every related object. It reproduces the +// behavior of callers that have not been narrowed to a specific subset. +func All() Config { + latestBuild := AllLatestBuild() + return Config{ + Template: true, + LatestBuild: &latestBuild, + } +} + +// AllLatestBuild returns the latest-build subtree with every node selected. +func AllLatestBuild() LatestBuild { + return LatestBuild{ + Job: &Job{QueuePosition: true}, + Resources: &Resources{ + Metadata: true, + Agents: &Agents{ + Apps: &Apps{Statuses: true}, + Scripts: true, + LogSources: true, + }, + }, + TemplateVersion: true, + } +} + +// AppStatuses reports whether app statuses +// (latest_build.resources.agents.apps.statuses) are selected. It is nil-safe so +// callers holding a possibly-nil subtree can descend without a chain of guards. +func (c *LatestBuild) AppStatuses() bool { + return c != nil && + c.Resources != nil && + c.Resources.Agents != nil && + c.Resources.Agents.Apps != nil && + c.Resources.Agents.Apps.Statuses +} diff --git a/coderd/wsrelated/related_test.go b/coderd/wsrelated/related_test.go new file mode 100644 index 00000000000..e36570aa0c9 --- /dev/null +++ b/coderd/wsrelated/related_test.go @@ -0,0 +1,42 @@ +package wsrelated_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/wsrelated" +) + +// TestAll verifies that All selects every node in the related-data hierarchy. +func TestAll(t *testing.T) { + t.Parallel() + + all := wsrelated.All() + require.True(t, all.Template) + require.NotNil(t, all.LatestBuild) + require.NotNil(t, all.LatestBuild.Job) + require.True(t, all.LatestBuild.Job.QueuePosition) + require.True(t, all.LatestBuild.TemplateVersion) + require.NotNil(t, all.LatestBuild.Resources) + require.True(t, all.LatestBuild.Resources.Metadata) + require.NotNil(t, all.LatestBuild.Resources.Agents) + require.NotNil(t, all.LatestBuild.Resources.Agents.Apps) + require.True(t, all.LatestBuild.Resources.Agents.Apps.Statuses) + require.True(t, all.LatestBuild.Resources.Agents.Scripts) + require.True(t, all.LatestBuild.Resources.Agents.LogSources) + require.True(t, all.LatestBuild.AppStatuses()) +} + +// TestLatestBuildAppStatuses verifies the nil-safe AppStatuses accessor only +// reports true when the full apps.statuses path is present. +func TestLatestBuildAppStatuses(t *testing.T) { + t.Parallel() + + require.False(t, (*wsrelated.LatestBuild)(nil).AppStatuses()) + require.False(t, (&wsrelated.LatestBuild{}).AppStatuses()) + require.False(t, (&wsrelated.LatestBuild{Resources: &wsrelated.Resources{}}).AppStatuses()) + require.False(t, (&wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{}}}).AppStatuses()) + require.False(t, (&wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{Apps: &wsrelated.Apps{}}}}).AppStatuses()) + require.True(t, (&wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{Apps: &wsrelated.Apps{Statuses: true}}}}).AppStatuses()) +} diff --git a/coderd/wsrelateddata.go b/coderd/wsrelateddata.go deleted file mode 100644 index 7e1f7e76400..00000000000 --- a/coderd/wsrelateddata.go +++ /dev/null @@ -1,87 +0,0 @@ -package coderd - -// workspaceRelated selects which workspace-related database objects to load when -// building a codersdk.Workspace. Loading a fully populated workspace is -// expensive, so callers use this to avoid querying data they will not use. -// -// The type is a tree that mirrors the parent/child relationships between those -// objects: a build has a job, resources, and a template version; a resource has -// agents; an agent has apps; and so on. Branch nodes are pointers that are -// non-nil when selected; leaf nodes are bools. Modeling it as a tree makes -// selecting a child without its parent unrepresentable, which is exactly the -// constraint loading requires: a parent must be queried to learn the -// identifiers of its children. -// -// A zero value (nil branches) selects nothing but the workspace itself. -// allWorkspaceRelated selects everything. -// -// The trailing comment on each field is that node's dotted path from the root -// of the tree, e.g. latest_build.resources.agents. -type workspaceRelated struct { - Template bool // template - LatestBuild *latestBuildRelated // latest_build -} - -type latestBuildRelated struct { - Job *jobRelated // latest_build.job - Resources *resourcesRelated // latest_build.resources - TemplateVersion bool // latest_build.template_version -} - -type jobRelated struct { - QueuePosition bool // latest_build.job.queue_position -} - -type resourcesRelated struct { - Metadata bool // latest_build.resources.metadata - Agents *agentsRelated // latest_build.resources.agents -} - -type agentsRelated struct { - Apps *appsRelated // latest_build.resources.agents.apps - Scripts bool // latest_build.resources.agents.scripts - LogSources bool // latest_build.resources.agents.log_sources -} - -type appsRelated struct { - Statuses bool // latest_build.resources.agents.apps.statuses -} - -// allWorkspaceRelated returns a selection that loads every related object. It -// reproduces the behavior of callers that have not been narrowed to a specific -// subset. -func allWorkspaceRelated() workspaceRelated { - latestBuild := allLatestBuildRelated() - return workspaceRelated{ - Template: true, - LatestBuild: &latestBuild, - } -} - -// allLatestBuildRelated returns the latest-build subtree with every node -// selected. -func allLatestBuildRelated() latestBuildRelated { - return latestBuildRelated{ - Job: &jobRelated{QueuePosition: true}, - Resources: &resourcesRelated{ - Metadata: true, - Agents: &agentsRelated{ - Apps: &appsRelated{Statuses: true}, - Scripts: true, - LogSources: true, - }, - }, - TemplateVersion: true, - } -} - -// appStatuses reports whether app statuses -// (latest_build.resources.agents.apps.statuses) are selected. It is nil-safe so -// callers holding a possibly-nil subtree can descend without a chain of guards. -func (c *latestBuildRelated) appStatuses() bool { - return c != nil && - c.Resources != nil && - c.Resources.Agents != nil && - c.Resources.Agents.Apps != nil && - c.Resources.Agents.Apps.Statuses -} diff --git a/coderd/wsrelateddata_internal_test.go b/coderd/wsrelateddata_internal_test.go index e95846df180..1295f94bfc3 100644 --- a/coderd/wsrelateddata_internal_test.go +++ b/coderd/wsrelateddata_internal_test.go @@ -9,43 +9,10 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/wsrelated" "github.com/coder/coder/v2/testutil" ) -// TestAllWorkspaceRelated verifies that allWorkspaceRelated selects every node -// in the related-data hierarchy. -func TestAllWorkspaceRelated(t *testing.T) { - t.Parallel() - - all := allWorkspaceRelated() - require.True(t, all.Template) - require.NotNil(t, all.LatestBuild) - require.NotNil(t, all.LatestBuild.Job) - require.True(t, all.LatestBuild.Job.QueuePosition) - require.True(t, all.LatestBuild.TemplateVersion) - require.NotNil(t, all.LatestBuild.Resources) - require.True(t, all.LatestBuild.Resources.Metadata) - require.NotNil(t, all.LatestBuild.Resources.Agents) - require.NotNil(t, all.LatestBuild.Resources.Agents.Apps) - require.True(t, all.LatestBuild.Resources.Agents.Apps.Statuses) - require.True(t, all.LatestBuild.Resources.Agents.Scripts) - require.True(t, all.LatestBuild.Resources.Agents.LogSources) - require.True(t, all.LatestBuild.appStatuses()) -} - -// TestLatestBuildRelatedAppStatuses verifies the nil-safe appStatuses accessor -// only reports true when the full apps.statuses path is present. -func TestLatestBuildRelatedAppStatuses(t *testing.T) { - t.Parallel() - - require.False(t, (*latestBuildRelated)(nil).appStatuses()) - require.False(t, (&latestBuildRelated{}).appStatuses()) - require.False(t, (&latestBuildRelated{Resources: &resourcesRelated{}}).appStatuses()) - require.False(t, (&latestBuildRelated{Resources: &resourcesRelated{Agents: &agentsRelated{}}}).appStatuses()) - require.False(t, (&latestBuildRelated{Resources: &resourcesRelated{Agents: &agentsRelated{Apps: &appsRelated{}}}}).appStatuses()) - require.True(t, (&latestBuildRelated{Resources: &resourcesRelated{Agents: &agentsRelated{Apps: &appsRelated{Statuses: true}}}}).appStatuses()) -} - // TestWorkspaceBuildsDataQueryGating asserts that workspaceBuildsData only // issues the database queries implied by the selection. gomock is strict, so // any query that is not set up here fails the test if invoked. @@ -93,37 +60,37 @@ func TestWorkspaceBuildsDataQueryGating(t *testing.T) { cases := []struct { name string - cfg latestBuildRelated + cfg wsrelated.LatestBuild setup func(*dbmock.MockStore) }{ { name: "BuildOnly", - cfg: latestBuildRelated{}, + cfg: wsrelated.LatestBuild{}, setup: func(*dbmock.MockStore) {}, }, { name: "Job", - cfg: latestBuildRelated{Job: &jobRelated{}}, + cfg: wsrelated.LatestBuild{Job: &wsrelated.Job{}}, setup: expectJob, }, { name: "JobWithQueuePosition", - cfg: latestBuildRelated{Job: &jobRelated{QueuePosition: true}}, + cfg: wsrelated.LatestBuild{Job: &wsrelated.Job{QueuePosition: true}}, setup: expectJobWithQueuePosition, }, { name: "TemplateVersion", - cfg: latestBuildRelated{TemplateVersion: true}, + cfg: wsrelated.LatestBuild{TemplateVersion: true}, setup: expectTemplateVersion, }, { name: "Resources", - cfg: latestBuildRelated{Resources: &resourcesRelated{}}, + cfg: wsrelated.LatestBuild{Resources: &wsrelated.Resources{}}, setup: expectResources, }, { name: "Metadata", - cfg: latestBuildRelated{Resources: &resourcesRelated{Metadata: true}}, + cfg: wsrelated.LatestBuild{Resources: &wsrelated.Resources{Metadata: true}}, setup: func(db *dbmock.MockStore) { expectResources(db) db.EXPECT().GetWorkspaceResourceMetadataByResourceIDs(gomock.Any(), gomock.Any()). @@ -132,7 +99,7 @@ func TestWorkspaceBuildsDataQueryGating(t *testing.T) { }, { name: "Agents", - cfg: latestBuildRelated{Resources: &resourcesRelated{Agents: &agentsRelated{}}}, + cfg: wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{}}}, setup: func(db *dbmock.MockStore) { expectResources(db) expectAgents(db) @@ -140,7 +107,7 @@ func TestWorkspaceBuildsDataQueryGating(t *testing.T) { }, { name: "Apps", - cfg: latestBuildRelated{Resources: &resourcesRelated{Agents: &agentsRelated{Apps: &appsRelated{}}}}, + cfg: wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{Apps: &wsrelated.Apps{}}}}, setup: func(db *dbmock.MockStore) { expectResources(db) expectAgents(db) @@ -149,7 +116,7 @@ func TestWorkspaceBuildsDataQueryGating(t *testing.T) { }, { name: "AppStatuses", - cfg: latestBuildRelated{Resources: &resourcesRelated{Agents: &agentsRelated{Apps: &appsRelated{Statuses: true}}}}, + cfg: wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{Apps: &wsrelated.Apps{Statuses: true}}}}, setup: func(db *dbmock.MockStore) { expectResources(db) expectAgents(db) @@ -160,7 +127,7 @@ func TestWorkspaceBuildsDataQueryGating(t *testing.T) { }, { name: "Scripts", - cfg: latestBuildRelated{Resources: &resourcesRelated{Agents: &agentsRelated{Scripts: true}}}, + cfg: wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{Scripts: true}}}, setup: func(db *dbmock.MockStore) { expectResources(db) expectAgents(db) @@ -170,7 +137,7 @@ func TestWorkspaceBuildsDataQueryGating(t *testing.T) { }, { name: "LogSources", - cfg: latestBuildRelated{Resources: &resourcesRelated{Agents: &agentsRelated{LogSources: true}}}, + cfg: wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{LogSources: true}}}, setup: func(db *dbmock.MockStore) { expectResources(db) expectAgents(db) @@ -180,7 +147,7 @@ func TestWorkspaceBuildsDataQueryGating(t *testing.T) { }, { name: "All", - cfg: allLatestBuildRelated(), + cfg: wsrelated.AllLatestBuild(), setup: func(db *dbmock.MockStore) { expectJobWithQueuePosition(db) expectTemplateVersion(db) @@ -229,7 +196,7 @@ func TestWorkspaceBuildsDataResourcesShortCircuit(t *testing.T) { db.EXPECT().GetWorkspaceResourcesByJobIDs(gomock.Any(), gomock.Any()). Return([]database.WorkspaceResource{}, nil) - cfg := latestBuildRelated{Resources: &resourcesRelated{Agents: &agentsRelated{Apps: &appsRelated{Statuses: true}}}} + cfg := wsrelated.LatestBuild{Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{Apps: &wsrelated.Apps{Statuses: true}}}} api := &API{Options: &Options{Database: db}} _, err := api.workspaceBuildsData(ctx, []database.WorkspaceBuild{build}, cfg) require.NoError(t, err) @@ -245,17 +212,17 @@ func TestWorkspaceDataQueryGating(t *testing.T) { cases := []struct { name string - cfg workspaceRelated + cfg wsrelated.Config setup func(*dbmock.MockStore) }{ { name: "None", - cfg: workspaceRelated{}, + cfg: wsrelated.Config{}, setup: func(*dbmock.MockStore) {}, }, { name: "Template", - cfg: workspaceRelated{Template: true}, + cfg: wsrelated.Config{Template: true}, setup: func(db *dbmock.MockStore) { db.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) @@ -263,7 +230,7 @@ func TestWorkspaceDataQueryGating(t *testing.T) { }, { name: "LatestBuild", - cfg: workspaceRelated{LatestBuild: &latestBuildRelated{}}, + cfg: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{}}, setup: func(db *dbmock.MockStore) { // No builds returned, so no build-subtree queries run. db.EXPECT().GetLatestWorkspaceBuildsByWorkspaceIDs(gomock.Any(), gomock.Any()). @@ -272,8 +239,8 @@ func TestWorkspaceDataQueryGating(t *testing.T) { }, { name: "AppStatuses", - cfg: workspaceRelated{LatestBuild: &latestBuildRelated{ - Resources: &resourcesRelated{Agents: &agentsRelated{Apps: &appsRelated{Statuses: true}}}, + cfg: wsrelated.Config{LatestBuild: &wsrelated.LatestBuild{ + Resources: &wsrelated.Resources{Agents: &wsrelated.Agents{Apps: &wsrelated.Apps{Statuses: true}}}, }}, setup: func(db *dbmock.MockStore) { // The workspace-level latest app status query is gated on the From 5a292bb4438fc40b142b99739a88e424cb24f081 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 11 Sep 2026 12:50:00 +0000 Subject: [PATCH 2/2] refactor(coderd/wsrelated): move ensure methods beside their structs --- coderd/wsrelated/parse.go | 35 ----------------------- coderd/wsrelated/related.go | 57 ++++++++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/coderd/wsrelated/parse.go b/coderd/wsrelated/parse.go index 9c75bba54f6..5bf027a0dbb 100644 --- a/coderd/wsrelated/parse.go +++ b/coderd/wsrelated/parse.go @@ -24,41 +24,6 @@ type relatedNode struct { children []relatedNode } -func (c *Config) ensureLatestBuild() *LatestBuild { - if c.LatestBuild == nil { - c.LatestBuild = &LatestBuild{} - } - return c.LatestBuild -} - -func (c *LatestBuild) ensureJob() *Job { - if c.Job == nil { - c.Job = &Job{} - } - return c.Job -} - -func (c *LatestBuild) ensureResources() *Resources { - if c.Resources == nil { - c.Resources = &Resources{} - } - return c.Resources -} - -func (c *Resources) ensureAgents() *Agents { - if c.Agents == nil { - c.Agents = &Agents{} - } - return c.Agents -} - -func (c *Agents) ensureApps() *Apps { - if c.Apps == nil { - c.Apps = &Apps{} - } - return c.Apps -} - // buildSchema constructs the include_related hierarchy. Each node's name matches // a segment in a dotted path, and its selectInto closure mirrors the // corresponding node in Config. diff --git a/coderd/wsrelated/related.go b/coderd/wsrelated/related.go index 1ffa7b1351b..fdab27340cc 100644 --- a/coderd/wsrelated/related.go +++ b/coderd/wsrelated/related.go @@ -22,12 +22,44 @@ type Config struct { LatestBuild *LatestBuild // latest_build } +func (c *Config) ensureLatestBuild() *LatestBuild { + if c.LatestBuild == nil { + c.LatestBuild = &LatestBuild{} + } + return c.LatestBuild +} + type LatestBuild struct { Job *Job // latest_build.job Resources *Resources // latest_build.resources TemplateVersion bool // latest_build.template_version } +func (c *LatestBuild) ensureJob() *Job { + if c.Job == nil { + c.Job = &Job{} + } + return c.Job +} + +func (c *LatestBuild) ensureResources() *Resources { + if c.Resources == nil { + c.Resources = &Resources{} + } + return c.Resources +} + +// AppStatuses reports whether app statuses +// (latest_build.resources.agents.apps.statuses) are selected. It is nil-safe so +// callers holding a possibly-nil subtree can descend without a chain of guards. +func (c *LatestBuild) AppStatuses() bool { + return c != nil && + c.Resources != nil && + c.Resources.Agents != nil && + c.Resources.Agents.Apps != nil && + c.Resources.Agents.Apps.Statuses +} + type Job struct { QueuePosition bool // latest_build.job.queue_position } @@ -37,12 +69,26 @@ type Resources struct { Agents *Agents // latest_build.resources.agents } +func (c *Resources) ensureAgents() *Agents { + if c.Agents == nil { + c.Agents = &Agents{} + } + return c.Agents +} + type Agents struct { Apps *Apps // latest_build.resources.agents.apps Scripts bool // latest_build.resources.agents.scripts LogSources bool // latest_build.resources.agents.log_sources } +func (c *Agents) ensureApps() *Apps { + if c.Apps == nil { + c.Apps = &Apps{} + } + return c.Apps +} + type Apps struct { Statuses bool // latest_build.resources.agents.apps.statuses } @@ -72,14 +118,3 @@ func AllLatestBuild() LatestBuild { TemplateVersion: true, } } - -// AppStatuses reports whether app statuses -// (latest_build.resources.agents.apps.statuses) are selected. It is nil-safe so -// callers holding a possibly-nil subtree can descend without a chain of guards. -func (c *LatestBuild) AppStatuses() bool { - return c != nil && - c.Resources != nil && - c.Resources.Agents != nil && - c.Resources.Agents.Apps != nil && - c.Resources.Agents.Apps.Statuses -}