Skip to main content

opys_engine/commands/
query.rs

1//! `opys query` — run SQL over the live corpus store.
2//!
3//! Read-only by default: a plan-guarded SELECT (nothing else can execute), and
4//! the command never flushes, so the files are unreachable. With `--write`,
5//! INSERT/UPDATE/DELETE run against the store, the normal sync pass reconciles
6//! and relocates, and the result is validated with `verify` — the files are
7//! written **only if verify passes**. So a raw SQL edit gets full power but can
8//! never leave the inventory in a state the CLI would reject; a corrupting edit
9//! changes nothing on disk (the store mutation is in-memory and simply not
10//! flushed).
11
12use std::io::Read as _;
13
14use crate::commands::verify;
15use crate::doc::Doc;
16use crate::error::{usage, Result};
17use crate::store::Store;
18use crate::Ctx;
19
20use super::stats;
21
22pub fn run(ctx: &Ctx, sql: &str, plain: bool, write: bool, stdin_value: bool) -> Result<()> {
23    let prj = ctx.open()?;
24    let sql_from_stdin = sql == "-";
25    let sql = if sql_from_stdin {
26        let mut buf = String::new();
27        std::io::stdin().read_to_string(&mut buf)?;
28        buf
29    } else {
30        sql.to_string()
31    };
32    if sql.trim().is_empty() {
33        return Err(usage("query: empty SQL"));
34    }
35    // `--stdin` binds stdin to `$1` in the SQL (large/multi-line values without
36    // SQL-escaping); it can't also read the SQL itself from stdin.
37    let params: Vec<String> = if stdin_value {
38        if sql_from_stdin {
39            return Err(usage(
40                "query: --stdin cannot combine with reading the SQL from stdin (-)",
41            ));
42        }
43        let mut buf = String::new();
44        std::io::stdin().read_to_string(&mut buf)?;
45        vec![buf.trim_end().to_string()]
46    } else {
47        Vec::new()
48    };
49
50    // Unparsable docs are warnings — query the parsable subset (list parity).
51    let (mut store, errors) = ctx.load(&prj)?;
52    for e in &errors {
53        eprintln!("warning: {e}");
54    }
55    store.refresh_projections(&prj.pcfg)?;
56
57    if write {
58        let baseline = store.baseline()?;
59        let before_blocks = block_texts(&mut store)?;
60        // Three seams: (1) apply the user's intent as raw DML, (2) the model
61        // pass reconciles the raw tables into a consistent model (cascades +
62        // sync), (3) the validator gates the result. Only edits that make
63        // verify *worse* are refused, so pre-existing issues never block a
64        // write. A future typed data model would replace seam 3 alone.
65        let before = verify_problems(&prj, &mut store, &errors)?;
66
67        let summary = store.run_user_write(&sql, &params).map_err(usage)?;
68        apply_block_edits(&prj, &mut store, &before_blocks)?;
69        reconcile_model(&prj, &mut store, &baseline, !ctx.no_sync)?;
70
71        let after = verify_problems(&prj, &mut store, &errors)?;
72        let new: Vec<String> = after.difference(&before).cloned().collect();
73        if !new.is_empty() {
74            eprintln!("refusing to write — the edit would introduce verify problems:");
75            for p in &new {
76                eprintln!("  {p}");
77            }
78            return Err(usage(format!(
79                "{} new problem(s); no files changed",
80                new.len()
81            )));
82        }
83        ctx.flush(&prj, store)?;
84        println!("query: {summary} (verified, written)");
85        return Ok(());
86    }
87
88    let (labels, rows) = store.run_user_query(&sql, &params).map_err(usage)?;
89    stats::print_markdown(&stats::table_body(&labels, &rows), plain);
90    Ok(())
91}
92
93/// All reconstructed docs (the shape verify's checks consume).
94fn docs_of(store: &mut Store) -> Result<Vec<Doc>> {
95    Ok(store.all_docs()?.into_iter().map(|(_, d)| d).collect())
96}
97
98/// Materialize documents created by a raw `INSERT INTO docs`. The store assigns
99/// a `dkey` to every row it inserts, so a NULL-`dkey` row is a user INSERT: give
100/// it the next id for its type (unless it supplied one), default its status,
101/// stamp timestamps, scaffold the body for its type, and re-insert it
102/// canonically. Tags/relations aren't set here — add them via the CLI or
103/// follow-up SQL. Runs before the verify gate, so a malformed insert is refused
104/// with no files changed.
105fn materialize_inserts(prj: &crate::project::Project, store: &mut Store) -> Result<()> {
106    use crate::store::g_str;
107    let (_, rows) = store.select(
108        "SELECT id, type, status, title, body FROM docs WHERE dkey IS NULL",
109        vec![],
110    )?;
111    if rows.is_empty() {
112        return Ok(());
113    }
114    let pcfg = &prj.pcfg;
115
116    // Extract + validate every inserted row before mutating the store, so an
117    // invalid INSERT changes nothing on disk.
118    struct Raw {
119        id: Option<String>,
120        type_name: String,
121        status: String,
122        title: String,
123        body: String,
124    }
125    let mut raws = Vec::with_capacity(rows.len());
126    for r in &rows {
127        let type_name = g_str(&r[1]).unwrap_or_default();
128        let t = pcfg.types.get(&type_name).ok_or_else(|| {
129            usage(format!(
130                "query: INSERT into docs needs a known type (got {type_name:?})"
131            ))
132        })?;
133        let status = {
134            let s = g_str(&r[2]).unwrap_or_default();
135            if s.is_empty() {
136                t.default_status.clone()
137            } else {
138                s
139            }
140        };
141        if !t.statuses.contains(&status) {
142            return Err(usage(format!(
143                "query: unknown status {status:?} for type {type_name:?}"
144            )));
145        }
146        let title = g_str(&r[3]).unwrap_or_default();
147        if title.trim().is_empty() {
148            return Err(usage(
149                "query: INSERT into docs needs a non-empty title".to_string(),
150            ));
151        }
152        raws.push(Raw {
153            id: g_str(&r[0]).filter(|s| !s.is_empty()),
154            type_name,
155            status,
156            title,
157            body: g_str(&r[4]).unwrap_or_default(),
158        });
159    }
160
161    // Swap the raw partial rows for canonical documents. Allocate ids one at a
162    // time so multiple inserts in one statement get distinct sequential ids.
163    store.exec("DELETE FROM docs WHERE dkey IS NULL", vec![])?;
164    for raw in raws {
165        let t = &pcfg.types[&raw.type_name];
166        let id = match raw.id {
167            Some(id) => id,
168            None => store.next_id_for(&t.prefix, pcfg.pad)?,
169        };
170        let mut fm = crate::frontmatter::Frontmatter::new();
171        fm.set_str("id", &id);
172        fm.set_str("status", &raw.status);
173        crate::commands::touch(&mut fm);
174        let body = if raw.body.trim().is_empty() {
175            crate::commands::new::scaffold_body(&raw.title, t)
176        } else {
177            raw.body
178        };
179        let doc = Doc {
180            path: prj.doc_path(&id, &raw.status),
181            frontmatter: fm,
182            body,
183            title: raw.title,
184        };
185        store.put_doc(pcfg, None, &doc)?;
186    }
187    Ok(())
188}
189
190/// The reactive model pass: bring the raw tables — freshly mutated by the user's
191/// DML — to a consistent model by materializing the lifecycle cascades a raw
192/// column edit can't express, then running the normal sync reconcile/linkify/
193/// relocate. It reacts to STATE deltas, never to the SQL text (shape-parsing is
194/// unreliable). Deliberately separate from validation (see [`verify_problems`])
195/// so a future typed data model can replace the validator without touching
196/// these cascades.
197fn reconcile_model(
198    prj: &crate::project::Project,
199    store: &mut Store,
200    baseline: &crate::store::Baseline,
201    run_sync: bool,
202) -> Result<()> {
203    // Removals: strike inbound references + reserve ids for docs the edit dropped.
204    store.cascade_removals(&prj.pcfg, baseline)?;
205    // Inserts: turn raw `INSERT INTO docs` rows into allocated, scaffolded docs.
206    materialize_inserts(prj, store)?;
207    if run_sync {
208        crate::commands::sync::pass(prj, store)?;
209    }
210    Ok(())
211}
212
213/// The validation seam: the current corpus's verify problems as a set. The
214/// write gate compares this before vs. after the model pass and refuses any
215/// edit that introduces a NEW problem — pre-existing issues never block an edit.
216/// A future typed data model would replace THIS function, leaving the model pass
217/// (see [`reconcile_model`]) untouched.
218fn verify_problems(
219    prj: &crate::project::Project,
220    store: &mut Store,
221    errors: &[String],
222) -> Result<std::collections::HashSet<String>> {
223    Ok(
224        verify::collect_problems(prj, &docs_of(store)?, errors.to_vec())
225            .into_iter()
226            .collect(),
227    )
228}
229
230/// Snapshot the `blocks` projection's section texts (`(doc_id, seq) -> text`),
231/// taken before user DML so [`apply_block_edits`] can find which sections were
232/// edited.
233fn block_texts(store: &mut Store) -> Result<std::collections::HashMap<(String, i64), String>> {
234    let (_, rows) = store.select("SELECT doc_id, seq, text FROM blocks", vec![])?;
235    let mut map = std::collections::HashMap::new();
236    for r in &rows {
237        if let (Some(id), Some(seq)) = (crate::store::g_str(&r[0]), crate::store::g_i64(&r[1])) {
238            map.insert((id, seq), crate::store::g_str(&r[2]).unwrap_or_default());
239        }
240    }
241    Ok(map)
242}
243
244/// Apply `UPDATE blocks SET text = …` edits back to the authoritative body: for
245/// every section whose text changed vs. `before`, splice the new content into
246/// the doc's body (byte-accurately, via `body::section_spans`) and bump its
247/// `updated`. Other `blocks` columns (heading/seq) and INSERT/DELETE on `blocks`
248/// are no-ops — the body is edited through `text` only.
249fn apply_block_edits(
250    prj: &crate::project::Project,
251    store: &mut Store,
252    before: &std::collections::HashMap<(String, i64), String>,
253) -> Result<()> {
254    let (_, rows) = store.select("SELECT doc_id, seq, text FROM blocks", vec![])?;
255    let mut by_doc: std::collections::BTreeMap<String, Vec<(usize, String)>> =
256        std::collections::BTreeMap::new();
257    for r in &rows {
258        let (Some(id), Some(seq)) = (crate::store::g_str(&r[0]), crate::store::g_i64(&r[1])) else {
259            continue;
260        };
261        let text = crate::store::g_str(&r[2]).unwrap_or_default();
262        if before.get(&(id.clone(), seq)).is_some_and(|t| *t != text) {
263            by_doc.entry(id).or_default().push((seq as usize, text));
264        }
265    }
266    if by_doc.is_empty() {
267        return Ok(());
268    }
269    for (doc_id, edits) in by_doc {
270        let Some(dkey) = store.dkey_opt(&doc_id)? else {
271            continue;
272        };
273        let mut doc = store.doc(dkey)?;
274        let spans = crate::body::section_spans(&doc.body);
275        doc.body = crate::body::apply_section_edits(&doc.body, &spans, &edits);
276        doc.title = crate::body::title(&doc.body);
277        super::touch(&mut doc.frontmatter);
278        store.put_doc(&prj.pcfg, Some(dkey), &doc)?;
279    }
280    Ok(())
281}