diff --git a/lib/api_projects/tests/projects.rs b/lib/api_projects/tests/projects.rs index 7fa154d799..ba3478b7af 100644 --- a/lib/api_projects/tests/projects.rs +++ b/lib/api_projects/tests/projects.rs @@ -7,7 +7,7 @@ //! Integration tests for project CRUD endpoints. use bencher_api_tests::TestServer; -use bencher_json::{JsonNewProject, JsonProject, JsonProjects, ProjectUuid}; +use bencher_json::{JsonNewProject, JsonProject, JsonProjects, ParameterFilter, ProjectUuid}; use http::StatusCode; // GET /v0/projects - list all public projects @@ -1224,3 +1224,249 @@ async fn plot_component_lists_dedupe() { let updated = patch_plot(&server, &user, project_slug, created.uuid, &patch).await; assert_eq!(updated.branches, vec![dims.branch2]); } + +// A plot's parameters filter is what the perf query's `parameters` param is: a +// value predicate over grid points, stored in its canonical form. +#[tokio::test] +async fn plot_parameters_round_trip_canonical() { + use bencher_api_tests::helpers::get_project_id; + + let server = TestServer::new().await; + let user = server + .signup("Plot User Params", "plotparams@example.com") + .await; + let org = server.create_org(&user, "Plot Org Params").await; + let project = server + .create_project(&user, &org, "Plot Project Params") + .await; + let project_slug: &str = project.slug.as_ref(); + let project_id = get_project_id(&server, project_slug); + + let dims = seed_plot_dimensions(&server, project_id); + // Scrambled order and two spellings of one number go in. + let created = post_plot( + &server, + &user, + project_slug, + &serde_json::json!({ + "lower_value": true, + "upper_value": true, + "lower_boundary": false, + "upper_boundary": false, + "x_axis": "date_time", + "window": 2_592_000, + "branches": [dims.branch1.to_string()], + "testbeds": [dims.testbed.to_string()], + "benchmarks": [dims.benchmark.to_string()], + "parameters": [ + { "size": 2 }, + { "size": 1.0 }, + { "size": 1 }, + ], + "measures": [dims.measure.to_string()], + }), + ) + .await; + // The canonical form comes back: sorted by canonical bytes, deduplicated. + let parameters = created.parameters.as_ref().expect("plot has a filter"); + assert_eq!(parameters.canonical(), r#"[{"size":1},{"size":2}]"#); + + // A read back through GET reports the same canonical filter. + let fetched: bencher_json::JsonPlot = server + .client + .get(server.api_url(&format!( + "/v0/projects/{project_slug}/plots/{}", + created.uuid + ))) + .header( + bencher_json::AUTHORIZATION, + bencher_json::bearer_header(&user.token), + ) + .send() + .await + .expect("Request failed") + .json() + .await + .expect("Failed to parse plot"); + assert_eq!( + fetched.parameters.as_ref().map(ParameterFilter::canonical), + Some(r#"[{"size":1},{"size":2}]"#.to_owned()) + ); + + // A patch replaces the filter wholesale. + let patch = serde_json::json!({ "parameters": [{ "size": 4, "threads": 8 }] }); + let updated = patch_plot(&server, &user, project_slug, created.uuid, &patch).await; + assert_eq!( + updated.parameters.as_ref().map(ParameterFilter::canonical), + Some(r#"[{"size":4,"threads":8}]"#.to_owned()) + ); + + // A patch that says nothing about the filter leaves it alone. + let untouched = patch_plot( + &server, + &user, + project_slug, + created.uuid, + &serde_json::json!({ "upper_boundary": true }), + ) + .await; + assert!(untouched.upper_boundary); + assert_eq!( + untouched + .parameters + .as_ref() + .map(ParameterFilter::canonical), + Some(r#"[{"size":4,"threads":8}]"#.to_owned()) + ); +} + +// A filter that matches every grid point is no filter at all: `null`, the empty +// list, and the list holding the empty set are one stored state, and so is a plot +// created without a filter at all. +#[tokio::test] +async fn plot_parameters_match_all_is_absent() { + use bencher_api_tests::helpers::get_project_id; + + let server = TestServer::new().await; + let user = server + .signup("Plot User Match All", "plotmatchall@example.com") + .await; + let org = server.create_org(&user, "Plot Org Match All").await; + let project = server + .create_project(&user, &org, "Plot Project Match All") + .await; + let project_slug: &str = project.slug.as_ref(); + let project_id = get_project_id(&server, project_slug); + + let dims = seed_plot_dimensions(&server, project_id); + let new_plot = |parameters: Option| { + let mut plot = serde_json::json!({ + "lower_value": true, + "upper_value": true, + "lower_boundary": false, + "upper_boundary": false, + "x_axis": "date_time", + "window": 2_592_000, + "branches": [dims.branch1.to_string()], + "testbeds": [dims.testbed.to_string()], + "benchmarks": [dims.benchmark.to_string()], + "measures": [dims.measure.to_string()], + }); + if let Some(parameters) = parameters { + plot["parameters"] = parameters; + } + plot + }; + + // Absent, the empty list, and the list holding the empty set all create a plot + // with no filter, and the response leaves the field out. + for parameters in [ + None, + Some(serde_json::json!([])), + Some(serde_json::json!([{}])), + Some(serde_json::Value::Null), + ] { + let created = post_plot(&server, &user, project_slug, &new_plot(parameters)).await; + assert!(created.parameters.is_none()); + let body = serde_json::to_value(&created).expect("Failed to serialize plot"); + assert!(body.get("parameters").is_none()); + } + + // A plot that names a filter goes back to every grid point on a `null` patch, + // and the same way on an empty list patch. + for clear in [serde_json::Value::Null, serde_json::json!([])] { + let created = post_plot( + &server, + &user, + project_slug, + &new_plot(Some(serde_json::json!([{ "size": 1 }]))), + ) + .await; + assert!(created.parameters.is_some()); + let cleared = patch_plot( + &server, + &user, + project_slug, + created.uuid, + &serde_json::json!({ "parameters": clear }), + ) + .await; + assert!(cleared.parameters.is_none()); + } +} + +// The filter is capped at eight sets, and the cap bounds what was written, before +// duplicates collapse. +#[tokio::test] +async fn plot_parameters_over_the_cap_rejected() { + use bencher_api_tests::helpers::get_project_id; + + let server = TestServer::new().await; + let user = server.signup("Plot User Cap", "plotcap@example.com").await; + let org = server.create_org(&user, "Plot Org Cap").await; + let project = server.create_project(&user, &org, "Plot Project Cap").await; + let project_slug: &str = project.slug.as_ref(); + let project_id = get_project_id(&server, project_slug); + + let dims = seed_plot_dimensions(&server, project_id); + let nine_sets = (0..9) + .map(|index| serde_json::json!({ "size": index })) + .collect::>(); + let eight_sets = nine_sets[..8].to_vec(); + + let plot_with = |parameters: &Vec| { + serde_json::json!({ + "lower_value": true, + "upper_value": true, + "lower_boundary": false, + "upper_boundary": false, + "x_axis": "date_time", + "window": 2_592_000, + "branches": [dims.branch1.to_string()], + "testbeds": [dims.testbed.to_string()], + "benchmarks": [dims.benchmark.to_string()], + "parameters": parameters, + "measures": [dims.measure.to_string()], + }) + }; + + let resp = server + .client + .post(server.api_url(&format!("/v0/projects/{project_slug}/plots"))) + .header( + bencher_json::AUTHORIZATION, + bencher_json::bearer_header(&user.token), + ) + .json(&plot_with(&nine_sets)) + .send() + .await + .expect("Request failed"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Eight is the cap, so eight is accepted. + let created = post_plot(&server, &user, project_slug, &plot_with(&eight_sets)).await; + assert_eq!( + created + .parameters + .as_ref() + .map(|parameters| parameters.sets().len()), + Some(8) + ); + + // The same cap applies on a patch. + let resp = server + .client + .patch(server.api_url(&format!( + "/v0/projects/{project_slug}/plots/{}", + created.uuid + ))) + .header( + bencher_json::AUTHORIZATION, + bencher_json::bearer_header(&user.token), + ) + .json(&serde_json::json!({ "parameters": nine_sets })) + .send() + .await + .expect("Request failed"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} diff --git a/lib/bencher_json/src/project/plot.rs b/lib/bencher_json/src/project/plot.rs index f5f5a5ac61..b399d7ff2e 100644 --- a/lib/bencher_json/src/project/plot.rs +++ b/lib/bencher_json/src/project/plot.rs @@ -8,7 +8,7 @@ use serde::{ de::{self, Visitor}, }; -use crate::{BenchmarkUuid, BranchUuid, MeasureUuid, ProjectUuid, TestbedUuid}; +use crate::{BenchmarkUuid, BranchUuid, MeasureUuid, ParameterFilter, ProjectUuid, TestbedUuid}; crate::typed_uuid::typed_uuid!(PlotUuid); @@ -52,6 +52,11 @@ pub struct JsonNewPlot { /// The benchmarks to include in the plot. /// At least one benchmark must be specified. pub benchmarks: Vec, + /// The grid points to include in the plot, as a list of parameter sets. + /// A grid point matches when any set in the list is a subset of it. + /// If not set, or set to an empty list, the plot includes every grid point. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option, /// The measures to include in the plot. /// At least one measure must be specified. pub measures: Vec, @@ -84,6 +89,10 @@ pub struct JsonPlot { pub branches: Vec, pub testbeds: Vec, pub benchmarks: Vec, + /// The grid points this plot draws, in canonical order. + /// Absent when the plot draws every grid point. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option, pub measures: Vec, pub created: DateTime, pub modified: DateTime, @@ -134,6 +143,13 @@ pub struct JsonPlotPatch { /// Replaces the current benchmarks for the plot. /// At least one benchmark must be specified. pub benchmarks: Option>, + /// The grid points to include in the plot, as a list of parameter sets. + /// Replaces the current filter for the plot. + /// Set to `null` or to an empty list to include every grid point again. + // Skipped when absent so a patch that leaves the filter alone says nothing + // about it: an explicit `null` on the wire clears the filter. + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option, /// The measures to include in the plot. /// Replaces the current measures for the plot. /// At least one measure must be specified. @@ -155,6 +171,8 @@ pub struct JsonPlotPatchNull { pub branches: Option>, pub testbeds: Option>, pub benchmarks: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option, pub measures: Option>, } @@ -176,6 +194,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { const BRANCHES_FIELD: &str = "branches"; const TESTBEDS_FIELD: &str = "testbeds"; const BENCHMARKS_FIELD: &str = "benchmarks"; + const PARAMETERS_FIELD: &str = "parameters"; const MEASURES_FIELD: &str = "measures"; const FIELDS: &[&str] = &[ INDEX_FIELD, @@ -190,6 +209,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { BRANCHES_FIELD, TESTBEDS_FIELD, BENCHMARKS_FIELD, + PARAMETERS_FIELD, MEASURES_FIELD, ]; @@ -208,6 +228,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { Branches, Testbeds, Benchmarks, + Parameters, Measures, } @@ -237,6 +258,11 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { let mut branches = None; let mut testbeds = None; let mut benchmarks = None; + // The outer option is whether the key was written, the inner is + // whether it was written as `null`. An absent filter leaves the + // plot's filter alone; an explicit `null` clears it, the same way + // an empty list does. + let mut parameters: Option> = None; let mut measures = None; while let Some(key) = map.next_key()? { @@ -313,6 +339,12 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { } benchmarks = Some(map.next_value()?); }, + Field::Parameters => { + if parameters.is_some() { + return Err(de::Error::duplicate_field(PARAMETERS_FIELD)); + } + parameters = Some(map.next_value()?); + }, Field::Measures => { if measures.is_some() { return Err(de::Error::duplicate_field(MEASURES_FIELD)); @@ -322,6 +354,12 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { } } + // An explicit `null` and an empty list are two spellings of one + // filter, the one that matches every grid point, so a written + // `null` folds to the canonical empty filter here and the two + // clear the plot's filter alike. + let parameters = parameters.map(Option::unwrap_or_default); + Ok(match title { Some(Some(title)) => Self::Value::Patch(JsonPlotPatch { index, @@ -336,6 +374,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { branches, testbeds, benchmarks, + parameters, measures, }), Some(None) => Self::Value::Null(JsonPlotPatchNull { @@ -351,6 +390,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { branches, testbeds, benchmarks, + parameters, measures, }), None => Self::Value::Patch(JsonPlotPatch { @@ -366,6 +406,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot { branches, testbeds, benchmarks, + parameters, measures, }), }) @@ -660,4 +701,76 @@ mod tests { serde_json::from_str::(r#"{"lower_value": true, "lower_value": false}"#) .unwrap_err(); } + + #[test] + fn deserialize_absent_parameters_leaves_filter_alone() { + let update: JsonUpdatePlot = serde_json::from_str(r#"{"lower_value": true}"#).unwrap(); + let JsonUpdatePlot::Patch(patch) = update else { + panic!("expected Patch variant"); + }; + assert!(patch.parameters.is_none()); + } + + #[test] + fn deserialize_null_and_empty_parameters_both_clear() { + for body in [r#"{"parameters": null}"#, r#"{"parameters": []}"#] { + let update: JsonUpdatePlot = serde_json::from_str(body).unwrap(); + let JsonUpdatePlot::Patch(patch) = update else { + panic!("expected Patch variant"); + }; + let parameters = patch.parameters.expect("parameters was written"); + assert!(parameters.is_match_all(), "{body}"); + } + } + + #[test] + fn deserialize_empty_set_parameters_clears() { + let update: JsonUpdatePlot = serde_json::from_str(r#"{"parameters": [{}]}"#).unwrap(); + let JsonUpdatePlot::Patch(patch) = update else { + panic!("expected Patch variant"); + }; + let parameters = patch.parameters.expect("parameters was written"); + assert!(parameters.is_match_all()); + } + + #[test] + fn deserialize_parameters_canonicalizes() { + // Scrambled order and two spellings of one number are one canonical filter. + let update: JsonUpdatePlot = + serde_json::from_str(r#"{"parameters": [{"size": 2}, {"size": 1.0}, {"size": 1}]}"#) + .unwrap(); + let JsonUpdatePlot::Patch(patch) = update else { + panic!("expected Patch variant"); + }; + let parameters = patch.parameters.expect("parameters was written"); + assert_eq!(parameters.canonical(), r#"[{"size":1},{"size":2}]"#); + } + + #[test] + fn deserialize_parameters_over_the_cap_errors() { + // The cap bounds what was written, before duplicates collapse. + let sets = (0..9) + .map(|index| format!(r#"{{"size":{index}}}"#)) + .collect::>() + .join(","); + serde_json::from_str::(&format!(r#"{{"parameters": [{sets}]}}"#)) + .unwrap_err(); + } + + #[test] + fn deserialize_duplicate_parameters_field_errors() { + serde_json::from_str::(r#"{"parameters": [], "parameters": []}"#) + .unwrap_err(); + } + + #[test] + fn deserialize_null_title_carries_parameters() { + let update: JsonUpdatePlot = + serde_json::from_str(r#"{"title": null, "parameters": [{"size": 1}]}"#).unwrap(); + let JsonUpdatePlot::Null(patch) = update else { + panic!("expected Null variant"); + }; + let parameters = patch.parameters.expect("parameters was written"); + assert_eq!(parameters.canonical(), r#"[{"size":1}]"#); + } } diff --git a/lib/bencher_schema/migrations/2026-08-26-140000_plot_parameters/down.sql b/lib/bencher_schema/migrations/2026-08-26-140000_plot_parameters/down.sql new file mode 100644 index 0000000000..e82cbe0e9f --- /dev/null +++ b/lib/bencher_schema/migrations/2026-08-26-140000_plot_parameters/down.sql @@ -0,0 +1,69 @@ +-- plot: remove the parameters column +-- +-- A plot that names a filter has no shape here, so the filter is dropped and every +-- plot draws every grid point again. +-- +-- SQLite drops every index a table owns along with the table, so the plot index is +-- recreated after the swap. +PRAGMA foreign_keys = off; + +DROP INDEX IF EXISTS index_plot_project_created; + +CREATE TABLE down_plot ( + id INTEGER PRIMARY KEY NOT NULL, + uuid TEXT NOT NULL UNIQUE, + project_id INTEGER NOT NULL, + rank BIGINT NOT NULL, + title TEXT, + lower_value BOOLEAN NOT NULL, + upper_value BOOLEAN NOT NULL, + lower_boundary BOOLEAN NOT NULL, + upper_boundary BOOLEAN NOT NULL, + x_axis INTEGER NOT NULL, + y_axis INTEGER NOT NULL, + window BIGINT NOT NULL, + created BIGINT NOT NULL, + modified BIGINT NOT NULL, + FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE +); + +INSERT INTO down_plot( + id, + uuid, + project_id, + rank, + title, + lower_value, + upper_value, + lower_boundary, + upper_boundary, + x_axis, + y_axis, + window, + created, + modified + ) +SELECT id, + uuid, + project_id, + rank, + title, + lower_value, + upper_value, + lower_boundary, + upper_boundary, + x_axis, + y_axis, + window, + created, + modified +FROM plot; + +DROP TABLE plot; + +ALTER TABLE down_plot + RENAME TO plot; + +CREATE INDEX index_plot_project_created ON plot(project_id, created); + +PRAGMA foreign_keys = on; diff --git a/lib/bencher_schema/migrations/2026-08-26-140000_plot_parameters/up.sql b/lib/bencher_schema/migrations/2026-08-26-140000_plot_parameters/up.sql new file mode 100644 index 0000000000..bfb9042d9b --- /dev/null +++ b/lib/bencher_schema/migrations/2026-08-26-140000_plot_parameters/up.sql @@ -0,0 +1,14 @@ +-- plot +-- The grid points a pinned plot draws. +-- +-- `parameters` is the filter over grid points: the SQLite JSONB encoding of a JSON +-- array of parameter sets, OR across the array and subset match within each set. +-- It is the same value the perf query's `parameters` takes, so a plot pins the +-- view it was pinned from. +-- +-- NULL is match all, which is what every plot that predates this migration draws, +-- so every existing row carries NULL and nothing about it moves. It is declared +-- `BLOB` to match the SQLite representation of the `Jsonb` SQL type, the same as +-- `threshold.parameters` and `parameter."set"`. +ALTER TABLE plot + ADD COLUMN parameters BLOB; diff --git a/lib/bencher_schema/src/model/project/plot/mod.rs b/lib/bencher_schema/src/model/project/plot/mod.rs index 005eba4de3..4d955957eb 100644 --- a/lib/bencher_schema/src/model/project/plot/mod.rs +++ b/lib/bencher_schema/src/model/project/plot/mod.rs @@ -1,6 +1,6 @@ use bencher_json::{ - BenchmarkUuid, BranchUuid, DateTime, Index, JsonNewPlot, JsonPlot, MeasureUuid, PlotUuid, - ResourceName, TestbedUuid, Window, + BenchmarkUuid, BranchUuid, DateTime, Index, JsonNewPlot, JsonPlot, MeasureUuid, + ParameterFilter, PlotUuid, ResourceName, TestbedUuid, Window, project::plot::{JsonPlotPatch, JsonPlotPatchNull, JsonUpdatePlot, XAxis, YAxis}, }; use bencher_rank::{Rank, RankGenerator, Ranked}; @@ -92,6 +92,8 @@ pub struct QueryPlot { pub window: Window, pub created: DateTime, pub modified: DateTime, + /// The grid points this plot draws, when it does not draw every one. + pub parameters: Option, } impl QueryPlot { @@ -158,6 +160,7 @@ impl QueryPlot { x_axis: None, y_axis: None, window: None, + parameters: None, modified: now, }; diesel::update(plot_table::table.filter(plot_table::id.eq(plot.id))) @@ -216,6 +219,7 @@ impl QueryPlot { x_axis: None, y_axis: None, window: None, + parameters: None, modified, }; diesel::update(plot_table::table.filter(plot_table::id.eq(plot.id))) @@ -258,6 +262,7 @@ impl QueryPlot { window, created, modified, + parameters, .. } = self; Ok(JsonPlot { @@ -274,6 +279,7 @@ impl QueryPlot { branches, testbeds, benchmarks, + parameters, measures, created, modified, @@ -299,6 +305,7 @@ impl QueryPlot { branches, testbeds, benchmarks, + parameters, measures, } = update.into(); @@ -366,6 +373,7 @@ impl QueryPlot { x_axis, y_axis, window, + parameters, modified, }; Self::apply_update( @@ -454,6 +462,9 @@ pub struct InsertPlot { pub window: Window, pub created: DateTime, pub modified: DateTime, + /// Stored canonically: a filter that draws every grid point is no filter at + /// all, so it is `NULL` here and absent on the wire. + pub parameters: Option, } impl InsertPlot { @@ -478,6 +489,7 @@ impl InsertPlot { branches, testbeds, benchmarks, + parameters, measures, } = plot; @@ -514,6 +526,7 @@ impl InsertPlot { window, created: timestamp, modified: timestamp, + parameters: parameters.and_then(canonical_parameters), }; let plot_id = conn .immediate_transaction(|conn| { @@ -551,11 +564,25 @@ pub struct UpdatePlot { pub x_axis: Option, pub y_axis: Option, pub window: Option, + /// `None` leaves the stored filter alone, `Some(None)` clears it back to every + /// grid point, and `Some(Some(filter))` replaces it. + pub parameters: Option>, pub modified: DateTime, } +/// The canonical stored form of a filter: a filter that draws every grid point is +/// no filter at all, so it is stored as `NULL` and read back absent. +/// +/// The canonicalization itself, per set and across the list, is +/// [`ParameterFilter`]'s own; this is only what turns a match all filter into the +/// absence of one, the same way a threshold's identity does. +fn canonical_parameters(parameters: ParameterFilter) -> Option { + (!parameters.is_match_all()).then_some(parameters) +} + /// The fields of a [`JsonUpdatePlot`], unified across its `Patch` and `Null` -/// variants. A `Some(None)` title clears the current title. +/// variants. A `Some(None)` title clears the current title, and a `Some(None)` +/// filter puts the plot back on every grid point. #[expect( clippy::option_option, reason = "None = not specified, Some(None) = explicitly unset" @@ -573,6 +600,7 @@ struct UpdatePlotFields { branches: Option>, testbeds: Option>, benchmarks: Option>, + parameters: Option>, measures: Option>, } @@ -593,6 +621,7 @@ impl From for UpdatePlotFields { branches, testbeds, benchmarks, + parameters, measures, } = patch; Self { @@ -608,6 +637,7 @@ impl From for UpdatePlotFields { branches, testbeds, benchmarks, + parameters: parameters.map(canonical_parameters), measures, } }, @@ -625,6 +655,7 @@ impl From for UpdatePlotFields { branches, testbeds, benchmarks, + parameters, measures, } = patch_null; Self { @@ -640,6 +671,7 @@ impl From for UpdatePlotFields { branches, testbeds, benchmarks, + parameters: parameters.map(canonical_parameters), measures, } }, @@ -955,6 +987,7 @@ mod tests { window: bencher_json::Window::try_from(2_592_000u32).unwrap(), created: timestamp, modified: timestamp, + parameters: None, }; diesel::insert_into(schema::plot::table) .values(&insert_plot) @@ -1129,6 +1162,7 @@ mod tests { x_axis: None, y_axis: None, window: None, + parameters: None, modified: DateTime::TEST, } } @@ -1198,6 +1232,7 @@ mod tests { x_axis: Some(XAxis::Version), y_axis: Some(YAxis::Log), window: None, + parameters: None, modified: DateTime::TEST, }; QueryPlot::apply_update(&mut conn, plot_id, &update_plot, None, None, None, None) @@ -1245,6 +1280,7 @@ mod tests { window: bencher_json::Window::try_from(2_592_000u32).unwrap(), created: timestamp, modified: timestamp, + parameters: None, }; diesel::insert_into(schema::plot::table) .values(&insert_plot) diff --git a/lib/bencher_schema/src/schema.rs b/lib/bencher_schema/src/schema.rs index e918c3bd4f..15c5dcd00b 100644 --- a/lib/bencher_schema/src/schema.rs +++ b/lib/bencher_schema/src/schema.rs @@ -212,6 +212,7 @@ diesel::table! { window -> BigInt, created -> BigInt, modified -> BigInt, + parameters -> Nullable, } } diff --git a/services/api/openapi.json b/services/api/openapi.json index 0abd0b68e1..da9792c24a 100644 --- a/services/api/openapi.json +++ b/services/api/openapi.json @@ -14290,6 +14290,15 @@ "$ref": "#/components/schemas/MeasureUuid" } }, + "parameters": { + "nullable": true, + "description": "The grid points to include in the plot, as a list of parameter sets. A grid point matches when any set in the list is a subset of it. If not set, or set to an empty list, the plot includes every grid point.", + "allOf": [ + { + "$ref": "#/components/schemas/ParameterFilter" + } + ] + }, "testbeds": { "description": "The testbeds to include in the plot. At least one testbed must be specified.", "type": "array", @@ -15788,6 +15797,15 @@ "modified": { "$ref": "#/components/schemas/DateTime" }, + "parameters": { + "nullable": true, + "description": "The grid points this plot draws, in canonical order. Absent when the plot draws every grid point.", + "allOf": [ + { + "$ref": "#/components/schemas/ParameterFilter" + } + ] + }, "project": { "$ref": "#/components/schemas/ProjectUuid" }, @@ -15888,6 +15906,15 @@ "$ref": "#/components/schemas/MeasureUuid" } }, + "parameters": { + "nullable": true, + "description": "The grid points to include in the plot, as a list of parameter sets. Replaces the current filter for the plot. Set to `null` or to an empty list to include every grid point again.", + "allOf": [ + { + "$ref": "#/components/schemas/ParameterFilter" + } + ] + }, "testbeds": { "nullable": true, "description": "The testbeds to include in the plot. Replaces the current testbeds for the plot. At least one testbed must be specified.", @@ -15984,6 +16011,14 @@ "$ref": "#/components/schemas/MeasureUuid" } }, + "parameters": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ParameterFilter" + } + ] + }, "testbeds": { "nullable": true, "type": "array", diff --git a/services/cli/src/bencher/sub/project/plot/create.rs b/services/cli/src/bencher/sub/project/plot/create.rs index f6514830e8..7224be5809 100644 --- a/services/cli/src/bencher/sub/project/plot/create.rs +++ b/services/cli/src/bencher/sub/project/plot/create.rs @@ -120,6 +120,9 @@ impl From for JsonNewPlot { branches: branches.into_iter().map(Into::into).collect(), testbeds: testbeds.into_iter().map(Into::into).collect(), benchmarks: benchmarks.into_iter().map(Into::into).collect(), + // The CLI has no parameters filter of its own yet, so the plot it + // creates draws every grid point. + parameters: None, measures: measures.into_iter().map(Into::into).collect(), } } diff --git a/services/cli/src/bencher/sub/project/plot/update.rs b/services/cli/src/bencher/sub/project/plot/update.rs index 33762d6aca..f060fa704d 100644 --- a/services/cli/src/bencher/sub/project/plot/update.rs +++ b/services/cli/src/bencher/sub/project/plot/update.rs @@ -134,6 +134,9 @@ impl From for JsonUpdatePlot { branches, testbeds, benchmarks, + // The CLI has no parameters filter of its own yet, so an + // update leaves the plot's filter alone. + parameters: None, measures, }), subtype_1: None, @@ -153,6 +156,9 @@ impl From for JsonUpdatePlot { branches, testbeds, benchmarks, + // The CLI has no parameters filter of its own yet, so an + // update leaves the plot's filter alone. + parameters: None, measures, }), }, @@ -170,6 +176,9 @@ impl From for JsonUpdatePlot { branches, testbeds, benchmarks, + // The CLI has no parameters filter of its own yet, so an + // update leaves the plot's filter alone. + parameters: None, measures, }), subtype_1: None, diff --git a/services/console/src/types/bencher.ts b/services/console/src/types/bencher.ts index 3a69a456af..eee68d25db 100644 --- a/services/console/src/types/bencher.ts +++ b/services/console/src/types/bencher.ts @@ -771,6 +771,12 @@ export interface JsonNewPlot { * At least one benchmark must be specified. */ benchmarks: Uuid[]; + /** + * The grid points to include in the plot, as a list of parameter sets. + * A grid point matches when any set in the list is a subset of it. + * If not set, or set to an empty list, the plot includes every grid point. + */ + parameters?: Record[]; /** * The measures to include in the plot. * At least one measure must be specified. @@ -1168,6 +1174,11 @@ export interface JsonPlot { branches: Uuid[]; testbeds: Uuid[]; benchmarks: Uuid[]; + /** + * The grid points this plot draws, in canonical order. + * Absent when the plot draws every grid point. + */ + parameters?: Record[]; measures: Uuid[]; created: string; modified: string;