Skip to main content

cortex/models/
corpora.rs

1#![allow(clippy::extra_unused_lifetimes)]
2use diesel::result::Error;
3use diesel::*;
4use serde::Serialize;
5use std::collections::HashMap;
6
7use crate::concerns::CortexInsertable;
8use crate::schema::corpora;
9use crate::schema::services;
10use crate::schema::tasks;
11
12use super::services::Service;
13
14// Corpora
15
16#[derive(Identifiable, Queryable, AsChangeset, Clone, Debug, Serialize)]
17#[diesel(table_name = corpora)]
18/// A minimal description of a document collection. Defined by a name, path and simple/complex file
19/// system setup.
20pub struct Corpus {
21  /// auto-incremented postgres id
22  pub id: i32,
23  /// file system path to corpus root
24  /// (a corpus is held in a single top-level directory)
25  pub path: String,
26  /// a human-readable name for this corpus
27  pub name: String,
28  /// are we using multiple files to represent a document entry?
29  /// (if unsure, always use "true")
30  pub complex: bool,
31  /// a human-readable description of the corpus, maybe allow markdown here?
32  pub description: String,
33  /// For a **sandbox** corpus: the parent corpus it was carved from (else `None`). See
34  /// [`crate::backend::sandbox`].
35  pub parent_corpus_id: Option<i32>,
36  /// For a **sandbox** corpus: the filter predicate it was built from — the JSON
37  /// `{service, severity, category, what}` selection over the parent. This IS the sandbox's
38  /// provenance ("why these entries"), so no per-task origin link is kept.
39  pub selection: Option<serde_json::Value>,
40  /// Stable external handle (UUIDv7, DB-generated), independent of the mutable `name` — for
41  /// public/API references that must survive a rename. Immutable once assigned (Arm 3 / D8).
42  pub public_id: uuid::Uuid,
43}
44
45diesel::define_sql_function! {
46  /// SQL `LOWER()`, for case-insensitive corpus-name matching in `find_by_name`.
47  fn lower(x: diesel::sql_types::Text) -> diesel::sql_types::Text;
48}
49
50impl Corpus {
51  /// ORM-like until diesel.rs introduces finders for more fields
52  pub fn find_by_name(name_query: &str, connection: &mut PgConnection) -> Result<Self, Error> {
53    use crate::schema::corpora::name;
54    // Case-insensitive: a corpus name matches regardless of case, so a mixed-case display name like
55    // `arXiv` resolves at any URL case (no functional index, but the corpora set is tiny). The
56    // stored case is preserved — callers render `corpus.name`, not the URL string.
57    corpora::table
58      .filter(lower(name).eq(name_query.to_lowercase()))
59      .first(connection)
60  }
61  /// Find a corpus by its primary key (used to resolve a sandbox's parent).
62  pub fn find_by_id(id_query: i32, connection: &mut PgConnection) -> Result<Self, Error> {
63    corpora::table.find(id_query).first(connection)
64  }
65  /// The id under which this corpus's **result archives** are name-scoped, or `None` for an
66  /// ordinary corpus. A sandbox (`parent_corpus_id.is_some()`) scopes its outputs by its own id
67  /// so a rerun can't clobber the parent's archives — see [`crate::helpers::result_archive_path`]
68  /// (F-6).
69  pub fn sandbox_id(&self) -> Option<i32> { self.parent_corpus_id.map(|_| self.id) }
70  /// Total number of tasks registered under this corpus (across all services) — the blast radius a
71  /// [`Corpus::destroy`] would remove. Used to preview a destructive delete before committing.
72  pub fn task_count(&self, connection: &mut PgConnection) -> Result<i64, Error> {
73    tasks::table
74      .filter(tasks::corpus_id.eq(self.id))
75      .count()
76      .get_result(connection)
77  }
78  /// Finds the **root** corpus registered at `path_query`. Sandboxes share their parent's path, so
79  /// they are excluded; the partial unique index `corpora_root_path_key` guarantees at most one
80  /// root per path, making the lookup deterministic.
81  pub fn find_by_path(path_query: &str, connection: &mut PgConnection) -> Result<Self, Error> {
82    corpora::table
83      .filter(corpora::path.eq(path_query))
84      .filter(corpora::parent_corpus_id.is_null())
85      .first(connection)
86  }
87  /// Returns all registered corpora, ordered by name.
88  pub fn all(connection: &mut PgConnection) -> Result<Vec<Self>, Error> {
89    corpora::table
90      .order(corpora::name.asc())
91      .get_results(connection)
92  }
93
94  /// Document count per corpus id, for **every** corpus in **one** query (no N+1) — the number of
95  /// `import`-service (id 2) tasks, which is one per ingested document. Used to show each corpus's
96  /// scale on the overview/landing without a per-corpus count. A corpus with no import tasks (none
97  /// ingested yet) is simply absent from the map (treat as 0).
98  pub fn document_counts(connection: &mut PgConnection) -> HashMap<i32, i64> {
99    use crate::schema::tasks::dsl::{corpus_id, service_id, tasks};
100    use diesel::dsl::sql;
101    use diesel::sql_types::BigInt;
102    // The magic `import` service id is 2 (1=init, 2=import). Raw `count(*)` mirrors
103    // `progress_report` and sidesteps Diesel's aggregate/group-by type check.
104    tasks
105      .select((corpus_id, sql::<BigInt>("count(*)")))
106      .filter(service_id.eq(2))
107      .group_by(corpus_id)
108      .load::<(i32, i64)>(connection)
109      .unwrap_or_default()
110      .into_iter()
111      .collect()
112  }
113  /// Return a hash representation of the corpus, usually for frontend reports
114  pub fn to_hash(&self) -> HashMap<String, String> {
115    let mut hm = HashMap::new();
116    hm.insert("name".to_string(), self.name.clone());
117    hm.insert("path".to_string(), self.path.clone());
118    hm.insert("description".to_string(), self.description.clone());
119    hm
120  }
121
122  /// Return a vector of services currently activated on this corpus
123  pub fn select_services(&self, connection: &mut PgConnection) -> Result<Vec<Service>, Error> {
124    use crate::schema::tasks::dsl::{corpus_id, service_id};
125    let corpus_service_ids_query = tasks::table
126      .select(service_id)
127      .distinct()
128      .filter(corpus_id.eq(self.id));
129    let services_query = services::table.filter(services::id.eq_any(corpus_service_ids_query));
130    let services: Vec<Service> = services_query.get_results(connection)?;
131    Ok(services)
132  }
133
134  /// Names of every sandbox carved from this corpus, directly or from one of its sandboxes — all
135  /// deleted with it (FK cascade).
136  pub fn sandboxes(&self, connection: &mut PgConnection) -> Result<Vec<String>, Error> {
137    let (mut names, mut frontier) = (Vec::new(), vec![self.id]);
138    // ponytail: one query per carve depth; 32 levels cuts a parent cycle, which only a hand-edited
139    // `parent_corpus_id` could make.
140    for _ in 0..32 {
141      if frontier.is_empty() {
142        break;
143      }
144      let children: Vec<(i32, String)> = corpora::table
145        .filter(corpora::parent_corpus_id.eq_any(&frontier))
146        .select((corpora::id, corpora::name))
147        .load(connection)?;
148      frontier = children.iter().map(|(id, _)| *id).collect();
149      names.extend(children.into_iter().map(|(_, name)| name));
150    }
151    names.sort();
152    names.dedup();
153    Ok(names)
154  }
155
156  /// Deletes a corpus and **all** its dependent rows — the `log_*` messages, the tasks, and the
157  /// corpus registration — consuming the object. Runs in a single transaction so a crash mid-delete
158  /// can't leave a half-deleted corpus (crash-consistency, `docs/DESIGN_PRINCIPLES.md`).
159  ///
160  /// The database cascades `corpora → tasks → log_*` (FKs since migration `…140000`); this deletes
161  /// the logs and tasks explicitly anyway, in one audited transaction, rather than relying on a
162  /// bare `DELETE FROM corpora`.
163  ///
164  /// Deleting a corpus also deletes every sandbox carved from it (and their tasks and logs) via the
165  /// `parent_corpus_id` FK cascade — list them first with [`Corpus::sandboxes`].
166  pub fn destroy(self, connection: &mut PgConnection) -> Result<usize, Error> {
167    use crate::schema::{log_errors, log_fatals, log_infos, log_invalids, log_warnings};
168    let corpus_id = self.id;
169    // A root's init task may sit under a placeholder corpus id (`examples/tex_to_html_import.rs`
170    // queues it before `InitWorker` registers the corpus), so roots also clear it by path — safe
171    // since a path names one root. Never for a sandbox: it shares its parent's path.
172    let root_path = self.parent_corpus_id.is_none().then_some(self.path);
173    connection.transaction(|t_connection| {
174      // The task ids of this corpus, rebuilt per delete (the subquery is consumed by `eq_any`).
175      let task_ids = || {
176        tasks::table
177          .filter(tasks::corpus_id.eq(corpus_id))
178          .select(tasks::id)
179      };
180      delete(log_infos::table.filter(log_infos::task_id.eq_any(task_ids())))
181        .execute(t_connection)?;
182      delete(log_warnings::table.filter(log_warnings::task_id.eq_any(task_ids())))
183        .execute(t_connection)?;
184      delete(log_errors::table.filter(log_errors::task_id.eq_any(task_ids())))
185        .execute(t_connection)?;
186      delete(log_fatals::table.filter(log_fatals::task_id.eq_any(task_ids())))
187        .execute(t_connection)?;
188      delete(log_invalids::table.filter(log_invalids::task_id.eq_any(task_ids())))
189        .execute(t_connection)?;
190      if let Some(path) = &root_path {
191        delete(tasks::table)
192          .filter(tasks::entry.eq(path))
193          .filter(tasks::service_id.eq(1))
194          .execute(t_connection)?;
195      }
196      // all tasks of this corpus, its init task included (cascades to historical_tasks via its FK).
197      delete(tasks::table)
198        .filter(tasks::corpus_id.eq(corpus_id))
199        .execute(t_connection)?;
200      // the corpus registration
201      delete(corpora::table)
202        .filter(corpora::id.eq(corpus_id))
203        .execute(t_connection)
204    })
205  }
206}
207
208/// Insertable `Corpus` struct
209#[derive(Insertable)]
210#[diesel(table_name = corpora)]
211pub struct NewCorpus {
212  /// file system path to corpus root
213  /// (a corpus is held in a single top-level directory)
214  pub path: String,
215  /// a human-readable name for this corpus
216  pub name: String,
217  /// are we using multiple files to represent a document entry?
218  /// (if unsure, always use "true")
219  pub complex: bool,
220  /// frontend-facing description of the corpus, maybe allow markdown here?
221  pub description: String,
222}
223impl Default for NewCorpus {
224  fn default() -> Self {
225    NewCorpus {
226      name: "mock corpus".to_string(),
227      path: ".".to_string(),
228      complex: true,
229      description: String::new(),
230    }
231  }
232}
233impl CortexInsertable for NewCorpus {
234  fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
235    insert_into(corpora::table).values(self).execute(connection)
236  }
237}
238
239/// Insertable for a **sandbox** corpus — a `NewCorpus` plus the parent link and the filter
240/// predicate that defines it. Kept separate from [`NewCorpus`] so ordinary corpus creation (the
241/// import path, and dozens of test fixtures) stays a 4-field literal; the two sandbox columns are
242/// nullable, so a plain `NewCorpus` insert simply leaves them `NULL`.
243#[derive(Insertable)]
244#[diesel(table_name = corpora)]
245pub struct NewSandboxCorpus {
246  /// file system path to corpus root — the sandbox references the **parent's** path in place
247  /// (sources are not copied; owner decision 2026-06-15).
248  pub path: String,
249  /// a human-readable name for this sandbox
250  pub name: String,
251  /// inherited from the parent (same on-disk topology)
252  pub complex: bool,
253  /// frontend-facing description (auto-generated from the selection)
254  pub description: String,
255  /// the parent corpus this sandbox was carved from
256  pub parent_corpus_id: Option<i32>,
257  /// the filter predicate (`{service, severity, category, what}`) — the sandbox's provenance
258  pub selection: Option<serde_json::Value>,
259}
260impl CortexInsertable for NewSandboxCorpus {
261  fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
262    insert_into(corpora::table).values(self).execute(connection)
263  }
264}