Skip to main content

spg_engine/
readonly.rs

1//! Read-only / snapshot execution, split out of `lib.rs` (lib.rs split
2//! 18). Two entry families share one module: the live read path
3//! (`execute_readonly` / `_with_cancel`, taken by the server under an
4//! `RwLock::read()` so SELECTs run in parallel) and the snapshot path
5//! (`execute_readonly_on_snapshot` / the prepared + describe variants /
6//! `is_readonly_sql` / `prepare_on_snapshot`), which run against a
7//! `CatalogSnapshot` without borrowing the engine. Both reject DDL/DML
8//! with `WriteRequired` and route SELECT / SHOW / EXPLAIN to the same
9//! domain handlers as the write path. Whole `impl Engine` methods; the
10//! public surface is unchanged, and `enforce_row_limit` stays in the
11//! crate root (shared with `execute.rs`, reached via self).
12
13use alloc::vec::Vec;
14
15use spg_sql::ast::Statement;
16use spg_sql::parser::{self, ParseError};
17use spg_storage::{ColumnSchema, Value};
18
19use crate::describe;
20use crate::{
21    CancelToken, CatalogSnapshot, Engine, EngineError, QueryResult, expand_group_by_all, reorder,
22    resolve_order_by_position, rewrite_clock_calls, substitute_placeholders,
23};
24
25impl Engine {
26    /// v7.11.1 — execute a read-only SQL statement against a
27    /// `CatalogSnapshot` without touching this engine. Same
28    /// semantics as `execute_readonly` but parameterised on the
29    /// snapshot's catalog. Reject DDL/DML the same way
30    /// `execute_readonly` does. Static-on-Self so the caller can
31    /// dispatch without holding an `Engine` borrow alongside the
32    /// snapshot.
33    pub fn execute_readonly_on_snapshot(
34        snapshot: &CatalogSnapshot,
35        sql: &str,
36    ) -> Result<QueryResult, EngineError> {
37        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
38    }
39
40    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
41    /// cancellation. Builds a transient `Engine` over the snapshot
42    /// state, runs `execute_readonly_with_cancel`, drops. The
43    /// transient engine is cheap to construct (no I/O; everything
44    /// is just struct moves) and lets the existing read path stay
45    /// untouched.
46    pub fn execute_readonly_on_snapshot_with_cancel(
47        snapshot: &CatalogSnapshot,
48        sql: &str,
49        cancel: CancelToken<'_>,
50    ) -> Result<QueryResult, EngineError> {
51        let transient = Engine {
52            catalog: snapshot.catalog.clone(),
53            statistics: snapshot.statistics.clone(),
54            clock: snapshot.clock,
55            max_query_rows: snapshot.max_query_rows,
56            ..Engine::default()
57        };
58        transient.execute_readonly_with_cancel(sql, cancel)
59    }
60
61    /// v7.18 — execute a previously-prepared `Statement` against a
62    /// `CatalogSnapshot` in read-only mode. Mirror of
63    /// [`Engine::execute_prepared`] for the fan-out read path:
64    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
65    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
66    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
67    /// so multiple readonly threads can dispatch against the same
68    /// snapshot concurrently without an `Engine` borrow.
69    ///
70    /// **Schema drift contract**. The `Statement` was prepared against
71    /// some prior catalog. If the snapshot's catalog has since
72    /// diverged (DDL renamed / dropped a referenced column / table),
73    /// execution surfaces the normal `EngineError` — same shape as
74    /// PG's "cached plan must not change result type". Caller decides
75    /// whether to re-prepare; engine does NOT auto-retry.
76    pub fn execute_readonly_prepared_on_snapshot(
77        snapshot: &CatalogSnapshot,
78        stmt: Statement,
79        params: &[Value<'static>],
80    ) -> Result<QueryResult, EngineError> {
81        Self::execute_readonly_prepared_on_snapshot_with_cancel(
82            snapshot,
83            stmt,
84            params,
85            CancelToken::none(),
86        )
87    }
88
89    /// v7.18 — cancellable variant of
90    /// [`Engine::execute_readonly_prepared_on_snapshot`].
91    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
92        snapshot: &CatalogSnapshot,
93        mut stmt: Statement,
94        params: &[Value<'static>],
95        cancel: CancelToken<'_>,
96    ) -> Result<QueryResult, EngineError> {
97        cancel.check()?;
98        substitute_placeholders(&mut stmt, params)?;
99        let transient = Engine {
100            catalog: snapshot.catalog.clone(),
101            statistics: snapshot.statistics.clone(),
102            clock: snapshot.clock,
103            max_query_rows: snapshot.max_query_rows,
104            ..Engine::default()
105        };
106        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
107    }
108
109    /// v7.18 — describe a prepared `Statement` against a
110    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
111    /// shape as [`Engine::describe_prepared`]; resolves names
112    /// against the snapshot's catalog instead of `self`. Pure
113    /// function — no engine state read.
114    pub fn describe_prepared_on_snapshot(
115        snapshot: &CatalogSnapshot,
116        stmt: &Statement,
117    ) -> (Vec<u32>, Vec<ColumnSchema>) {
118        describe::describe_prepared(stmt, &snapshot.catalog)
119    }
120
121    /// v7.18 — does this SQL string classify as read-only? Parses
122    /// `sql` with the engine parser and consults
123    /// `Statement::is_readonly()`. A parse error returns `false`
124    /// (route to the writer path so the user sees the canonical
125    /// parse error from the writer's simple-query dispatch).
126    /// Static-on-Self so the spg-sqlx connection layer can ask
127    /// without an `Engine` borrow.
128    #[must_use]
129    pub fn is_readonly_sql(sql: &str) -> bool {
130        parser::parse_statement(sql)
131            .as_ref()
132            .map(spg_sql::ast::Statement::is_readonly)
133            .unwrap_or(false)
134    }
135
136    /// v7.18 — parse + plan a SQL string against a
137    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
138    /// readonly fan-out path: applies the same prepare-time
139    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
140    /// BY position resolve, cost-based JOIN reorder) but resolves
141    /// catalog + statistics against the snapshot, not a live
142    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
143    /// this without taking the writer lock so multiple read
144    /// handles can prepare concurrently against frozen views.
145    ///
146    /// # Errors
147    /// Propagates [`ParseError`] from the parser. Schema
148    /// validation deferred to execute time, same as
149    /// [`Engine::prepare`].
150    pub fn prepare_on_snapshot(
151        snapshot: &CatalogSnapshot,
152        sql: &str,
153    ) -> Result<Statement, ParseError> {
154        let mut stmt = parser::parse_statement(sql)?;
155        let now_micros = snapshot.clock.map(|f| f());
156        rewrite_clock_calls(&mut stmt, now_micros);
157        if let Statement::Select(s) = &mut stmt {
158            expand_group_by_all(s);
159            resolve_order_by_position(s);
160            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
161        }
162        Ok(stmt)
163    }
164
165    /// **v4.0 concurrency**: this is the entry point the server takes
166    /// under an `RwLock::read()` so multiple `SELECT` clients run in
167    /// parallel without serialising on a single mutex.
168    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
169        self.execute_readonly_with_cancel(sql, CancelToken::none())
170    }
171
172    /// v7.37.x (SPGS PROJ wire encode tax) — read-path streaming
173    /// SELECT. Parses the SQL, applies the same statement-level
174    /// rewrites the read path does (`rewrite_clock_calls`,
175    /// `resolve_order_by_position`, `reorder::reorder_joins`), then
176    /// drives the streaming SELECT executor with the caller's emit
177    /// callback. For PROJ-shape SQLs (joined non-aggregate projection
178    /// of bound columns over thousands of rows) the engine produces
179    /// each row to the emit fn WITHOUT materialising the result into
180    /// `Vec<Row<'static>>` — the per-cell `.cloned()` and per-row
181    /// `Row::new(values)` disappear. On the 25 k-row PROJ shape
182    /// that's about 4 ms saved (one less full result allocation pass
183    /// at the engine output boundary).
184    ///
185    /// Returns the surviving row count emitted (post-WHERE,
186    /// post-LIMIT) for the `CommandComplete` tag. Non-SELECT
187    /// statements surface as `Unsupported` so the caller can fall
188    /// back to the materialising read path.
189    /// v7.37.x (docker-fair SCALARSQ wire-overhead attack) — prepared-
190    /// SelectStatement variant. Caller has already run
191    /// `parser::parse_statement_with` + `rewrite_clock_calls` +
192    /// `resolve_order_by_position` + `reorder::reorder_joins` (the
193    /// per-connection parse cache in spg-server's pgwire layer caches
194    /// the post-prepare AST and re-applies `rewrite_clock_calls` per
195    /// invocation since the clock value embedded in the AST drifts).
196    /// Otherwise identical to the SQL-string entry point.
197    pub fn prepare_select_streaming(
198        &self,
199        sql: &str,
200    ) -> Result<spg_sql::ast::SelectStatement, EngineError> {
201        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
202        let now_micros = self.clock.map(|f| f());
203        rewrite_clock_calls(&mut stmt, now_micros);
204        let Statement::Select(mut s) = stmt else {
205            return Err(EngineError::Unsupported(
206                "prepare_select_streaming: not a SELECT".into(),
207            ));
208        };
209        resolve_order_by_position(&mut s);
210        reorder::reorder_joins(&mut s, &self.catalog, &self.statistics);
211        Ok(s)
212    }
213
214    /// Re-apply `rewrite_clock_calls` to a previously-prepared AST
215    /// (cache-friendly: the cached AST's embedded clock literal gets
216    /// re-pointed to current time without re-parsing).
217    pub fn refresh_clock(&self, s: &mut spg_sql::ast::SelectStatement) {
218        let now_micros = self.clock.map(|f| f());
219        if now_micros.is_none() {
220            return;
221        }
222        // Wrap as Statement::Select temporarily to reuse the public
223        // walker; cheap (one enum tag manipulation).
224        let mut stmt = Statement::Select(core::mem::take(s));
225        rewrite_clock_calls(&mut stmt, now_micros);
226        if let Statement::Select(rewritten) = stmt {
227            *s = rewritten;
228        }
229    }
230
231    /// v7.37.x (docker-fair SCALARSQ wire-overhead attack) — prepared
232    /// SELECT that returns the full materialised `QueryResult` instead
233    /// of driving an emit closure per row. The streaming variant is
234    /// only a win when the engine can stream rows lazily (joined
235    /// non-aggregate projection through `try_exec_joined_streaming`);
236    /// for shapes that materialise inside the engine anyway (anything
237    /// with a subquery — including the SCALARSQ shape — and most
238    /// aggregates), the emit closure dispatch + cell_refs Vec
239    /// management add ~25-50 µs / 100-row response for zero benefit.
240    /// This API lets the caller skip the streaming wrapper entirely
241    /// and iterate the result rows directly into the wire encoder.
242    pub fn execute_readonly_select_prepared(
243        &self,
244        s: &spg_sql::ast::SelectStatement,
245        cancel: CancelToken<'_>,
246    ) -> Result<QueryResult, EngineError> {
247        cancel.check()?;
248        self.exec_select_cancel(s, cancel)
249    }
250
251    /// v7.37.42-arena Phase 2 — arena-aware streaming SELECT API.
252    /// On SCALARSQ streaming-shape detection (`is_scalarsq_streaming_
253    /// shape`), routes to `exec_scalarsq_streaming` and emits each
254    /// projected row straight out of an arena-backed `bumpalo::Vec`
255    /// scratch — no `Vec<Row<'static>>` ever materialises in the
256    /// engine for this shape.
257    ///
258    /// Non-streaming shapes fall through to the generic
259    /// `exec_select_cancel` materialised path and emit row-by-row
260    /// off the returned `Vec<Row>`; callers stay shape-blind.
261    ///
262    /// Caller passes a `&'a Bump`; per-row projection scratch lives
263    /// in that arena and drops in O(1) at the caller's
264    /// `Bump::reset()` / scope end. This is the SPG equivalent of
265    /// PG's per-query MessageContext / printtup pattern.
266    ///
267    /// The shape check is fast (~10 boolean field reads + items
268    /// walk); calling on every prepared SELECT is fine.
269    pub fn execute_readonly_select_with_arena<'a, F>(
270        &self,
271        s: &spg_sql::ast::SelectStatement,
272        cancel: CancelToken<'_>,
273        arena: &'a bumpalo::Bump,
274        mut emit: F,
275    ) -> Result<(Vec<spg_storage::ColumnSchema>, usize), EngineError>
276    where
277        F: FnMut(
278            &[spg_storage::ColumnSchema],
279            &[spg_storage::Value<'a>],
280        ) -> Result<(), EngineError>,
281    {
282        cancel.check()?;
283        if crate::scalarsq_streaming::is_scalarsq_streaming_shape(s) {
284            return self.exec_scalarsq_streaming(s, cancel, arena, emit);
285        }
286        // Generic fallback — same as `execute_readonly_select_prepared`
287        // but adapted to the streaming-shape API's columns+row
288        // callback signature. The arena isn't used here (cells are
289        // owned `Value<'static>`); the win for the fallback shape
290        // lands in later phases.
291        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
292            return Err(EngineError::Unsupported(
293                "execute_readonly_select_with_arena fallback got a non-Rows result".into(),
294            ));
295        };
296        for row in &rows {
297            // `&[Value<'static>]` satisfies `&[Value<'a>]` via
298            // covariance of `Cow<'a, str>` in `'a`.
299            emit(&columns, &row.values)?;
300        }
301        let n = rows.len();
302        Ok((columns, n))
303    }
304
305    pub fn execute_readonly_select_streaming_prepared<F>(
306        &self,
307        s: &spg_sql::ast::SelectStatement,
308        cancel: CancelToken<'_>,
309        mut emit: F,
310    ) -> Result<usize, EngineError>
311    where
312        F: FnMut(crate::StreamItem<'_>) -> Result<(), EngineError>,
313    {
314        cancel.check()?;
315        if !crate::expr_tree_has_subquery(s)
316            && let Some(n) = self.try_exec_joined_streaming(s, cancel, &mut emit)?
317        {
318            return Ok(n);
319        }
320        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
321            return Err(EngineError::Unsupported(
322                "streaming SELECT got a non-Rows result".into(),
323            ));
324        };
325        emit(crate::StreamItem::Header(&columns))?;
326        let mut cell_refs: Vec<&Value> = Vec::with_capacity(columns.len());
327        for row in &rows {
328            cell_refs.clear();
329            for v in &row.values {
330                cell_refs.push(v);
331            }
332            emit(crate::StreamItem::Row(&cell_refs))?;
333        }
334        Ok(rows.len())
335    }
336
337    pub fn execute_readonly_select_streaming<F>(
338        &self,
339        sql: &str,
340        cancel: CancelToken<'_>,
341        mut emit: F,
342    ) -> Result<usize, EngineError>
343    where
344        F: FnMut(crate::StreamItem<'_>) -> Result<(), EngineError>,
345    {
346        cancel.check()?;
347        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
348        let now_micros = self.clock.map(|f| f());
349        rewrite_clock_calls(&mut stmt, now_micros);
350        let Statement::Select(mut s) = stmt else {
351            return Err(EngineError::Unsupported(
352                "execute_readonly_select_streaming: not a SELECT".into(),
353            ));
354        };
355        resolve_order_by_position(&mut s);
356        reorder::reorder_joins(&mut s, &self.catalog, &self.statistics);
357        // Streaming fast path: joined non-aggregate projection of
358        // bound columns. Falls back to the materialising path inside
359        // `try_exec_joined_streaming` returning None for any shape
360        // that needs the full result (aggregate, ORDER BY, DISTINCT,
361        // subqueries, etc.) — the caller's `Vec<Row<'static>>` round-trip
362        // still wins because Engine::execute path keeps materialising.
363        if !crate::expr_tree_has_subquery(&s)
364            && let Some(n) = self.try_exec_joined_streaming(&s, cancel, &mut emit)?
365        {
366            return Ok(n);
367        }
368        // Fall back: materialise then iterate. Mirrors the bottom
369        // half of `exec_select_streaming` (execute.rs) but at the
370        // read path — no `&mut self`, no `current_tx` flip.
371        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(&s, cancel)? else {
372            return Err(EngineError::Unsupported(
373                "streaming SELECT got a non-Rows result".into(),
374            ));
375        };
376        emit(crate::StreamItem::Header(&columns))?;
377        let mut cell_refs: Vec<&Value> = Vec::with_capacity(columns.len());
378        for row in &rows {
379            cell_refs.clear();
380            for v in &row.values {
381                cell_refs.push(v);
382            }
383            emit(crate::StreamItem::Row(&cell_refs))?;
384        }
385        Ok(rows.len())
386    }
387
388    /// v4.5 — read path with cooperative cancellation. Token's
389    /// `is_cancelled` is checked at the start (so a watchdog that
390    /// already fired returns Cancelled immediately) and at row-loop
391    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
392    /// don't bother checking.
393    pub fn execute_readonly_with_cancel(
394        &self,
395        sql: &str,
396        cancel: CancelToken<'_>,
397    ) -> Result<QueryResult, EngineError> {
398        cancel.check()?;
399        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
400        let now_micros = self.clock.map(|f| f());
401        rewrite_clock_calls(&mut stmt, now_micros);
402        if let Statement::Select(s) = &mut stmt {
403            resolve_order_by_position(s);
404            // v6.2.3 — cost-based JOIN reorder (read path).
405            reorder::reorder_joins(s, &self.catalog, &self.statistics);
406        }
407        self.execute_readonly_stmt_with_cancel(stmt, cancel)
408    }
409
410    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
411    /// Internal helper shared by the SQL-string path
412    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
413    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
414    /// Statement-level transforms (clock rewrite, ORDER BY position,
415    /// JOIN reorder, placeholder substitution) are the caller's
416    /// responsibility — this helper assumes the AST is already
417    /// execution-ready. Writes / DDL hit
418    /// [`EngineError::WriteRequired`] the same way the SQL path does.
419    fn execute_readonly_stmt_with_cancel(
420        &self,
421        stmt: Statement,
422        cancel: CancelToken<'_>,
423    ) -> Result<QueryResult, EngineError> {
424        let result = match stmt {
425            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
426            Statement::ShowTables => Ok(self.exec_show_tables()),
427            Statement::ShowDatabases => Ok(self.exec_show_databases()),
428            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
429            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
430            Statement::ShowStatus => Ok(self.exec_show_status()),
431            Statement::ShowVariables => Ok(self.exec_show_variables()),
432            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
433            Statement::ShowColumns(table) => self.exec_show_columns(&table),
434            Statement::ShowUsers => Ok(self.exec_show_users()),
435            Statement::ShowPublications => Ok(self.exec_show_publications()),
436            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
437            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
438                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
439            )),
440            Statement::Explain(e) => self.exec_explain(&e, cancel),
441            _ => Err(EngineError::WriteRequired),
442        };
443        self.enforce_row_limit(result)
444    }
445}