Skip to main content

teamcraft/services/
workset.rs

1//! The working set of a revision: everything the desktop "Get" copies into
2//! the working folder to open an assembly in the CAD program.
3//!
4//! The multi-level BOM is resolved with a revision rule, and the CAD files
5//! the resolved files reference (an assembly names its part files, a drawing
6//! its model – see `cadrefs`) pull in the items that hold them, with the same
7//! rule, until nothing new turns up. Every file comes with its folder inside
8//! the project group, its current version and SHA-256, so the client copies
9//! only what is missing or outdated, and with the lock of its revision.
10
11use 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
26/// More revisions than this are refused (a "Get" of half the vault is a
27/// mistake, not a working set).
28pub 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/// Why a revision is part of the working set.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Via {
44    Root,
45    Bom,
46    /// A CAD file of the set references one of its files.
47    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    /// Items of the BOM (or referenced by CAD files) without a revision
73    /// that matches the rule.
74    pub unresolved: Vec<String>,
75    /// File names referenced inside CAD files that are not in the vault.
76    pub missing_references: Vec<String>,
77    /// BOM lines to items the reader may not see (skipped, no identity).
78    pub restricted_lines: usize,
79    pub file_count: usize,
80    pub total_bytes: i64,
81    /// Database queries the resolution took (for the performance tests).
82    #[serde(skip)]
83    pub queries: usize,
84}
85
86/// Resolves the working set below a revision.
87///
88/// The BOM is read level by level (`bom::load_structure`, one query per
89/// BOM level) and walked without expanding shared sub-assemblies again;
90/// each round of CAD references takes three queries (names, holders, their
91/// revisions under the rule), whatever the number of files.
92pub 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    // Items already in the set keep the revision the BOM chose: one
108    // revision per item, as one file can sit in the folder only once.
109    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        // Names already looked up, so a name is resolved only once.
120        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            // Holder items new to the set, in the order of `holders`.
145            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        // + the revisions and their files
242        queries: queries + 2,
243    })
244}
245
246/// Lower-case file names the current versions of the files of these
247/// revisions reference, without the names those revisions hold themselves.
248async 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
264/// The items whose revisions hold a file of one of these names, as
265/// `(item id, lower-case name)`. If a name is found in the given project
266/// group, items of other groups with the same file name are ignored.
267async 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}