Skip to content

Commit d9344d4

Browse files
committed
unique_name
1 parent c0891ee commit d9344d4

File tree

6 files changed

+96
-8
lines changed

6 files changed

+96
-8
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ oso = "0.27"
7171
paste = "1.0"
7272
pretty_assertions = "1.4"
7373
rand = "0.9"
74+
regex = "1.11"
75+
regex-lite = "0.1"
7476
reqwest = { version = "0.12", default-features = false }
7577
schemars = { version = "0.8", features = ["uuid1"] }
7678
sentry = { version = "0.36", default-features = false, features = [

lib/bencher_schema/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ derive_more.workspace = true
3838
diesel = { workspace = true, features = ["chrono", "sqlite"] }
3939
dropshot.workspace = true
4040
http.workspace = true
41-
serde_json = {workspace = true, optional = true}
41+
regex.workspace = true
42+
serde_json = { workspace = true, optional = true }
4243
serde_urlencoded.workspace = true
4344
oso.workspace = true
4445
reqwest = { workspace = true, optional = true, features = ["rustls-tls"] }

lib/bencher_schema/src/model/project/mod.rs

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
1-
use std::string::ToString;
1+
use std::{string::ToString, sync::LazyLock};
22

33
use bencher_json::{
44
project::{JsonProjectPatch, JsonProjectPatchNull, JsonUpdateProject, ProjectRole, Visibility},
55
DateTime, JsonNewProject, JsonProject, ProjectUuid, ResourceId, ResourceIdKind, ResourceName,
66
Slug, Url,
77
};
88
use bencher_rbac::{project::Permission, Organization, Project};
9-
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
9+
use diesel::{
10+
BoolExpressionMethods, ExpressionMethods, QueryDsl, RunQueryDsl, TextExpressionMethods,
11+
};
1012
use dropshot::HttpError;
1113
use project_role::InsertProjectRole;
14+
use regex::Regex;
1215
use slog::Logger;
1316

1417
use crate::{
1518
conn_lock,
1619
context::{DbConnection, Rbac},
1720
error::{
18-
assert_parentage, forbidden_error, resource_conflict_err, resource_not_found_err,
19-
resource_not_found_error, unauthorized_error, BencherResource,
21+
assert_parentage, forbidden_error, issue_error, resource_conflict_err,
22+
resource_not_found_err, resource_not_found_error, unauthorized_error, BencherResource,
2023
},
2124
macros::{
2225
fn_get::{fn_from_uuid, fn_get, fn_get_uuid},
@@ -43,6 +46,10 @@ pub mod threshold;
4346

4447
crate::macros::typed_id::typed_id!(ProjectId);
4548

49+
static UNIQUE_SUFFIX: LazyLock<Regex> = LazyLock::new(|| {
50+
Regex::new(r"\((\d+)\)$").expect("Failed to create regex for unique project suffix")
51+
});
52+
4653
#[derive(
4754
Debug, Clone, diesel::Queryable, diesel::Identifiable, diesel::Associations, diesel::Selectable,
4855
)]
@@ -124,6 +131,7 @@ impl QueryProject {
124131
}
125132

126133
let query_organization = QueryOrganization::get_or_create(context, auth_user).await?;
134+
let project_name = Self::unique_name(context, &query_organization, project_name).await?;
127135
let json_project = JsonNewProject {
128136
name: project_name,
129137
slug: Some(project_slug),
@@ -133,6 +141,82 @@ impl QueryProject {
133141
Self::create(log, context, auth_user, &query_organization, json_project).await
134142
}
135143

144+
async fn unique_name(
145+
context: &ApiContext,
146+
query_organization: &QueryOrganization,
147+
project_name: ResourceName,
148+
) -> Result<ResourceName, HttpError> {
149+
const SPACE_PAREN_LEN: usize = 3;
150+
let max_name_len = ResourceName::MAX_LEN - i64::MAX.to_string().len() - SPACE_PAREN_LEN;
151+
152+
// This needs to happen before we escape the project name
153+
// so we check the possibly truncated name for originality
154+
let name_str = if project_name.as_ref().len() > max_name_len {
155+
const ELLIPSES_LEN: usize = 3;
156+
// The max length for a `usize` is 20 characters,
157+
// so we don't have to worry about the number suffix being too long.
158+
project_name
159+
.as_ref()
160+
.chars()
161+
.take(max_name_len - ELLIPSES_LEN)
162+
.chain(".".repeat(ELLIPSES_LEN).chars())
163+
.collect::<String>()
164+
} else {
165+
project_name.to_string()
166+
};
167+
168+
// Escape the project name for use in a regex pattern
169+
let escaped_name = regex::escape(&name_str);
170+
// Create a regex pattern to match the original project name or any subsequent projects with the same name
171+
let pattern = format!(r"^{escaped_name} \(\d+\)$");
172+
173+
let Ok(highest_name) = schema::project::table
174+
.filter(schema::project::organization_id.eq(query_organization.id))
175+
.filter(
176+
schema::project::name
177+
.eq(&project_name)
178+
.or(schema::project::name.like(&pattern)),
179+
)
180+
.select(schema::project::name)
181+
.order(schema::project::name.desc())
182+
.first::<ResourceName>(conn_lock!(context))
183+
else {
184+
// The project name is already unique
185+
return Ok(project_name);
186+
};
187+
188+
let next_number = if highest_name == project_name {
189+
1
190+
} else if let Some(caps) = UNIQUE_SUFFIX.captures(highest_name.as_ref()) {
191+
let last_number: usize = caps
192+
.get(1)
193+
.and_then(|m| m.as_str().parse().ok())
194+
.ok_or_else(|| {
195+
issue_error(
196+
"Failed to parse project number",
197+
&format!("Failed to parse number from project ({highest_name})"),
198+
highest_name,
199+
)
200+
})?;
201+
last_number + 1
202+
} else {
203+
return Err(issue_error(
204+
"Failed to create new project number",
205+
&format!("Failed to create new number for project ({project_name}) with highest project ({highest_name})"),
206+
highest_name,
207+
));
208+
};
209+
210+
let name_with_suffix = format!("{name_str} ({next_number})");
211+
name_with_suffix.parse().map_err(|e| {
212+
issue_error(
213+
"Failed to create new project name",
214+
&format!("Failed to create new project name ({name_with_suffix})",),
215+
e,
216+
)
217+
})
218+
}
219+
136220
pub async fn create(
137221
log: &Logger,
138222
context: &ApiContext,

lib/bencher_valid/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ derive_more.workspace = true
3030
diesel = { workspace = true, optional = true }
3131
ordered-float = { workspace = true, features = ["serde"] }
3232
rand = { workspace = true, optional = true }
33+
regex = { workspace = true, optional = true }
34+
regex-lite = { workspace = true, optional = true }
3335
schemars = { workspace = true, optional = true, features = ["chrono"] }
3436
serde.workspace = true
3537
serde_json = { workspace = true, optional = true }
@@ -44,8 +46,6 @@ console_error_panic_hook = { version = "0.1", optional = true }
4446
email_address = "0.2"
4547
gix-hash = "0.16"
4648
git-validate = "0.7"
47-
regex = { version = "1.11", optional = true }
48-
regex-lite = { version = "0.1", optional = true }
4949
wasm-bindgen = { version = "0.2", optional = true }
5050

5151
[dev-dependencies]

services/cli/src/bencher/sub/project/run/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::{future::Future, pin::Pin};
22

33
use bencher_client::types::{Adapter, JsonAverage, JsonFold, JsonNewReport, JsonReportSettings};
44
use bencher_comment::ReportComment;
5-
use bencher_json::{DateTime, JsonReport, NameId, ResourceId, RunContext};
5+
use bencher_json::{DateTime, JsonReport, NameId, ResourceId};
66

77
use crate::{
88
bencher::backend::AuthBackend,

0 commit comments

Comments
 (0)