1use std::collections::{HashMap, HashSet};
12
13use chrono::{DateTime, Utc};
14use serde::Serialize;
15use sqlx::PgPool;
16use uuid::Uuid;
17
18use crate::{
19 auth::Actor,
20 domain::RevisionRule,
21 error::{AppError, AppResult},
22 models::Revision,
23 services::{access, bom, files, revisions},
24};
25
26pub const MAX_REVISIONS: usize = 5000;
29
30#[derive(Clone, Debug, Serialize)]
31pub struct WorksetFile {
32 pub file_id: Uuid,
33 pub filename: String,
34 pub folder: String,
35 pub version: i32,
36 pub sha256: String,
37 pub size_bytes: i64,
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Via {
44 Root,
45 Bom,
46 Reference,
48}
49
50#[derive(Clone, Debug, Serialize)]
51pub struct WorksetRevision {
52 pub revision_id: Uuid,
53 pub item_number: String,
54 pub code: String,
55 pub state: String,
56 pub name: String,
57 pub project_code: Option<String>,
58 pub via: Via,
59 pub locked_by: Option<Uuid>,
60 pub locked_by_name: Option<String>,
61 pub locked_at: Option<DateTime<Utc>>,
62 pub locked_by_me: bool,
63 pub files: Vec<WorksetFile>,
64}
65
66#[derive(Clone, Debug, Serialize)]
67pub struct Workset {
68 pub rule: RevisionRule,
69 pub root_item_number: String,
70 pub root_code: String,
71 pub revisions: Vec<WorksetRevision>,
72 pub unresolved: Vec<String>,
75 pub missing_references: Vec<String>,
77 pub restricted_lines: usize,
79 pub file_count: usize,
80 pub total_bytes: i64,
81 #[serde(skip)]
83 pub queries: usize,
84}
85
86pub async fn resolve(
93 pool: &PgPool,
94 actor: &Actor,
95 root_revision_id: Uuid,
96 rule: RevisionRule,
97 with_references: bool,
98) -> AppResult<Workset> {
99 let root = revisions::get(pool, actor, root_revision_id).await?;
100 let (lines_by_rev, mut queries) = bom::load_structure(pool, actor, root.id, rule).await?;
101 queries += 1;
102 let structure = bom::structure_revisions(root.id, &lines_by_rev);
103 let structure_restricted = structure.restricted_lines;
104 drop(lines_by_rev);
105 let mut order: Vec<(Uuid, Via)> = vec![(root.id, Via::Root)];
106 let mut seen: HashSet<Uuid> = HashSet::from([root.id]);
107 let mut items_done: HashSet<Uuid> = HashSet::from([root.item_id]);
110 for (rev_id, item_id) in structure.revisions {
111 if seen.insert(rev_id) {
112 order.push((rev_id, Via::Bom));
113 }
114 items_done.insert(item_id);
115 }
116 let mut unresolved: Vec<String> = structure.unresolved;
117 let mut missing_references = Vec::new();
118 if with_references {
119 let mut looked_up: HashSet<String> = HashSet::new();
121 let mut frontier: Vec<Uuid> = order.iter().map(|(id, _)| *id).collect();
122 while !frontier.is_empty() {
123 if seen.len() > MAX_REVISIONS {
124 break;
125 }
126 let names = referenced_names(pool, &frontier).await?;
127 queries += 1;
128 let names: Vec<String> = names
129 .into_iter()
130 .filter(|n| looked_up.insert(n.clone()))
131 .collect();
132 frontier.clear();
133 if names.is_empty() {
134 break;
135 }
136 let holders = holders_of(pool, actor, &names, root.project_id).await?;
137 queries += 1;
138 let found: HashSet<&str> = holders.iter().map(|(_, k)| k.as_str()).collect();
139 for name in &names {
140 if !found.contains(name.as_str()) {
141 missing_references.push(name.clone());
142 }
143 }
144 let mut new_items: Vec<Uuid> = Vec::new();
146 for (item_id, _) in &holders {
147 if items_done.insert(*item_id) {
148 new_items.push(*item_id);
149 }
150 }
151 if new_items.is_empty() {
152 continue;
153 }
154 let resolved = bom::resolve_items(pool, &new_items, rule).await?;
155 queries += 1;
156 for item_id in &new_items {
157 let Some(item) = resolved.get(item_id) else {
158 continue;
159 };
160 match item.revision_id {
161 Some(rev_id) => {
162 if seen.insert(rev_id) {
163 order.push((rev_id, Via::Reference));
164 frontier.push(rev_id);
165 }
166 }
167 None => {
168 if !unresolved.contains(&item.number) {
169 unresolved.push(item.number.clone());
170 }
171 }
172 }
173 }
174 }
175 }
176 if seen.len() > MAX_REVISIONS {
177 return Err(AppError::BadRequest(format!(
178 "The working set is too large (more than {MAX_REVISIONS} revisions) – get its sub-assemblies one by one."
179 )));
180 }
181 missing_references.sort();
182 missing_references.dedup();
183
184 let ids: Vec<Uuid> = order.iter().map(|(id, _)| *id).collect();
185 let revs = sqlx::query_as::<_, Revision>(&format!(
186 "{} WHERE r.id = ANY($1)",
187 revisions::REVISION_SELECT
188 ))
189 .bind(&ids)
190 .fetch_all(pool)
191 .await?;
192 let mut by_id: HashMap<Uuid, Revision> = revs.into_iter().map(|r| (r.id, r)).collect();
193 let mut files_by_rev: HashMap<Uuid, Vec<WorksetFile>> = HashMap::new();
194 for f in files::list_many(pool, &ids).await? {
195 files_by_rev
196 .entry(f.revision_id)
197 .or_default()
198 .push(WorksetFile {
199 file_id: f.id,
200 filename: f.filename,
201 folder: f.folder,
202 version: f.version,
203 sha256: f.sha256,
204 size_bytes: f.size_bytes,
205 });
206 }
207 let mut out = Vec::with_capacity(order.len());
208 let (mut file_count, mut total_bytes) = (0usize, 0i64);
209 for (id, via) in order {
210 let Some(rev) = by_id.remove(&id) else {
211 continue;
212 };
213 let files = files_by_rev.remove(&id).unwrap_or_default();
214 file_count += files.len();
215 total_bytes += files.iter().map(|f| f.size_bytes).sum::<i64>();
216 out.push(WorksetRevision {
217 revision_id: rev.id,
218 locked_by_me: rev.checked_out_by == Some(actor.id),
219 item_number: rev.item_number,
220 code: rev.code,
221 state: rev.state,
222 name: rev.name,
223 project_code: rev.project_code,
224 via,
225 locked_by: rev.checked_out_by,
226 locked_by_name: rev.checked_out_by_name,
227 locked_at: rev.checked_out_at,
228 files,
229 });
230 }
231 Ok(Workset {
232 rule,
233 root_item_number: root.item_number,
234 root_code: root.code,
235 revisions: out,
236 unresolved,
237 missing_references,
238 restricted_lines: structure_restricted,
239 file_count,
240 total_bytes,
241 queries: queries + 2,
243 })
244}
245
246async fn referenced_names(pool: &PgPool, revision_ids: &[Uuid]) -> AppResult<Vec<String>> {
249 Ok(sqlx::query_scalar::<_, String>(
250 "SELECT DISTINCT lower(fr.name)
251 FROM files f
252 JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
253 JOIN file_references fr ON fr.file_version_id = fv.id
254 WHERE f.revision_id = ANY($1)
255 AND lower(fr.name) NOT IN (SELECT lower(o.filename) FROM files o
256 WHERE o.revision_id = ANY($1))
257 ORDER BY 1",
258 )
259 .bind(revision_ids)
260 .fetch_all(pool)
261 .await?)
262}
263
264async fn holders_of(
268 pool: &PgPool,
269 actor: &Actor,
270 names: &[String],
271 project_id: Option<Uuid>,
272) -> AppResult<Vec<(Uuid, String)>> {
273 #[derive(sqlx::FromRow)]
274 struct Row {
275 item_id: Uuid,
276 key: String,
277 same_project: bool,
278 }
279 let rows = sqlx::query_as::<_, Row>(&format!(
280 "SELECT DISTINCT i.id AS item_id, lower(f.filename) AS key,
281 i.project_id IS NOT DISTINCT FROM $2 AS same_project
282 FROM files f
283 JOIN revisions r ON r.id = f.revision_id
284 JOIN items i ON i.id = r.item_id
285 WHERE lower(f.filename) = ANY($1) AND {}
286 ORDER BY 2, 1",
287 access::item_visible("i", 3)
288 ))
289 .bind(names)
290 .bind(project_id)
291 .bind(&actor.hidden_projects)
292 .fetch_all(pool)
293 .await?;
294 let in_project: HashSet<String> = rows
295 .iter()
296 .filter(|r| r.same_project)
297 .map(|r| r.key.clone())
298 .collect();
299 Ok(rows
300 .into_iter()
301 .filter(|r| r.same_project || !in_project.contains(&r.key))
302 .map(|r| (r.item_id, r.key))
303 .collect())
304}