reddb_server/runtime/statement_frame.rs
1use std::cell::RefCell;
2use std::collections::HashSet;
3use std::rc::Rc;
4use std::sync::Arc;
5
6use super::execution_context::capture_current_snapshot;
7use super::impl_core::{
8 collections_referenced, current_auth_identity, current_connection_id, current_tenant,
9 has_with_prefix, intent_lock_modes_for, peek_top_level_as_of_with_table,
10 query_has_volatile_builtin, query_is_ask_statement, ConfigSnapshotGuard, CurrentSnapshotGuard,
11 KvStoreGuard, SecretStoreGuard, SnapshotContext, TxLocalTenantGuard,
12};
13use super::{RedDBRuntime, RuntimeQueryResult, RuntimeResultCacheEntry};
14use crate::api::{RedDBError, RedDBResult};
15use crate::auth::Role;
16use crate::storage::transaction::snapshot::{Snapshot, Xid};
17use reddb_rql::ast::QueryExpr;
18use reddb_rql::modes::{detect_mode, parse_multi, QueryMode};
19
20/// Coarse privilege classification for a statement, computed once at
21/// frame-build time from the SQL text. Mirrors the three-role auth
22/// model (`Role::Read < Role::Write < Role::Admin`) so the frame can
23/// answer "can this identity run this statement?" without re-walking
24/// the parsed `QueryExpr` at every call site.
25///
26/// `None` means the statement does not touch the privilege gate at
27/// all (transaction control, SET, SHOW). Such statements must remain
28/// runnable under any authenticated identity.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub(crate) enum Privilege {
31 /// Read-only data access (SELECT, EXPLAIN, SHOW). Satisfied by
32 /// any role from `Role::Read` upward.
33 Read,
34 /// Mutation of user data or schema author DDL (INSERT, UPDATE,
35 /// DELETE, CREATE/ALTER/DROP TABLE, CREATE MIGRATION). Requires
36 /// at least `Role::Write`.
37 Write,
38 /// Authority statements — GRANT, REVOKE, ALTER USER, APPLY /
39 /// ROLLBACK MIGRATION, IAM policy mutation. Requires `Role::Admin`.
40 Admin,
41 /// Statement does not consult the privilege gate (BEGIN, COMMIT,
42 /// ROLLBACK, SET, SHOW with no data exposure). Always permitted
43 /// for any authenticated identity.
44 None,
45}
46
47impl Privilege {
48 /// `true` iff `role` is sufficient to execute a statement carrying
49 /// this required privilege. Encodes the standard `Read ⊆ Write ⊆
50 /// Admin` containment used by the auth fallback path.
51 pub(crate) fn is_satisfied_by(self, role: Role) -> bool {
52 match self {
53 Self::None => true,
54 Self::Read => role.can_read(),
55 Self::Write => role.can_write(),
56 Self::Admin => role.can_admin(),
57 }
58 }
59}
60
61/// Coarse lock intent for a statement, computed once at frame-build
62/// time. Maps onto the storage-layer's `LockMode` matrix downstream
63/// but stays decoupled here so the runtime can answer "does this
64/// statement need the lock manager at all?" without a `use storage::`
65/// at every call site.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub(crate) enum LockIntent {
68 /// No collection-level lock needed (transaction control, SET,
69 /// SHOW, EXPLAIN). The lock-acquisition path can short-circuit.
70 None,
71 /// Reader-style intent: SELECT, joins, graph / queue / search
72 /// reads. Maps to `(IS, IS)` at the storage layer.
73 Shared,
74 /// Writer- or DDL-style intent: INSERT/UPDATE/DELETE (`(IX, IX)`)
75 /// and CREATE/ALTER/DROP (`(IX, X)`). Both are surfaced as
76 /// `Exclusive` at this granularity — call sites that need the
77 /// finer distinction still consult `intent_lock_modes_for`.
78 Exclusive,
79}
80
81/// Small, stable Interface that *represents* a read statement's
82/// execution context. Every read caller that needs to know "under
83/// what scope / identity / snapshot am I running, and is there an
84/// AS OF floor in effect?" consults this trait — never the
85/// underlying thread-locals or runtime fields directly.
86///
87/// The deletion test: removing this trait would force the four
88/// concerns it exposes back into ad-hoc lookups at every read
89/// callsite (`current_tenant()`, `current_auth_identity()`,
90/// `capture_current_snapshot()`, AS OF re-parsing). The trait
91/// concentrates them in one place so future changes (per-statement
92/// logging, audit, scope policy) have a single seam to extend.
93pub(crate) trait ReadFrame {
94 /// Effective tenant scope for the statement after WITHIN /
95 /// SET LOCAL TENANT / SET TENANT resolution. `None` means
96 /// "no tenant bound" (RLS deny-default applies).
97 fn effective_scope(&self) -> Option<&str>;
98
99 /// Authenticated identity observed at frame-build time, if any.
100 /// Returns `(username, role)` so callers can render audit lines
101 /// or feed RLS policy lookups without re-reading thread-locals.
102 fn identity(&self) -> Option<(&str, Role)>;
103
104 /// MVCC snapshot the statement reads against. For autocommit
105 /// this is a fresh snapshot; inside an active transaction it
106 /// is the txn's snapshot; under AS OF it is the resolved
107 /// historical xid.
108 fn snapshot(&self) -> &Snapshot;
109
110 /// Complete visibility context, including the reader's savepoint writes.
111 fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
112 SnapshotContext {
113 snapshot: self.snapshot().clone(),
114 manager: runtime.inner.transaction_state.snapshot_manager(),
115 own_xids: HashSet::new(),
116 requires_index_fallback: true,
117 serializable_reader: None,
118 }
119 }
120
121 /// AS OF xid floor when AS OF was applied for this statement,
122 /// `None` for live reads. Useful for downstream callers that
123 /// want to gate behaviour on historical-read mode without
124 /// re-parsing the query.
125 fn as_of_floor(&self) -> Option<Xid>;
126
127 /// Stable result-cache key for the statement (already mixes
128 /// effective tenant + identity).
129 fn cache_key(&self) -> &str;
130
131 /// Whether the statement is safe to serve from / populate the
132 /// result cache. Combines two underlying signals:
133 ///
134 /// * the query does not call a volatile builtin (e.g. `NOW()`,
135 /// `RANDOM()`, `UUID()`), which would change between calls,
136 /// * the connection is not inside an active transaction with
137 /// uncommitted writes that other readers shouldn't observe.
138 ///
139 /// SELECT cache callsites (read + write) consult this method
140 /// instead of re-deriving safety from globals or poking the
141 /// frame's private fields. Removing it would force every cache
142 /// callsite to re-run `query_has_volatile_builtin` plus
143 /// `result_cache_safe(conn_id)` inline.
144 fn should_cache_result(&self) -> bool;
145
146 /// Coarse privilege class the statement requires, computed once
147 /// at frame-build time from the SQL prefix. Read/write dispatch
148 /// sites consult this instead of re-classifying the parsed
149 /// `QueryExpr` inline at every callsite.
150 ///
151 /// Removing this method would force every privilege gate to
152 /// recompute the (action, resource) classification from the
153 /// parsed expression and re-check the role hierarchy inline.
154 fn required_privilege(&self) -> Privilege;
155
156 /// Coarse collection-level lock intent the statement implies.
157 /// `None` lets the lock-acquisition path short-circuit without
158 /// touching the lock manager.
159 ///
160 /// Removing this method would force the lock-acquisition path
161 /// to always invoke `intent_lock_modes_for` (which itself walks
162 /// the parsed expression) even for transaction-control / SET /
163 /// SHOW statements that need no collection lock at all.
164 fn lock_intent(&self) -> LockIntent;
165
166 /// Set of collection ids the calling identity is allowed to
167 /// observe under the active `(tenant, role)` scope. Computed once
168 /// at frame-build time via the `AuthStore` visible-collections
169 /// cache (see `auth::scope_cache`) and used by `AuthorizedSearch`
170 /// to pre-filter SEARCH SIMILAR / SEARCH CONTEXT candidate sets
171 /// before any similarity score is computed (issue #119).
172 ///
173 /// `None` means the frame was built without an auth store wired —
174 /// embedded / single-tenant tests run that way. AI search call
175 /// sites refuse to proceed with `None`, which is the deny-default
176 /// the issue requires; pure SELECT paths fall back to the existing
177 /// per-row RLS gate.
178 fn visible_collections(&self) -> Option<&std::collections::HashSet<String>>;
179}
180
181/// Cheap first-word classification of a SQL statement, used at
182/// frame-build time to derive `Privilege` + `LockIntent` without
183/// re-parsing the query. Matches the keywords that the legacy
184/// inline checks in `RedDBRuntime::check_query_privilege` and
185/// `intent_lock_modes_for` already key on.
186/// Whether [`statement_kind`] reads `query` as a mutating statement.
187/// The Request seam uses it to keep its pre-write commit-policy check off
188/// read paths.
189pub(crate) fn statement_is_write(query: &str) -> bool {
190 statement_kind(query) == "write"
191}
192
193fn statement_kind(query: &str) -> &'static str {
194 let trimmed = query.trim_start();
195 // Skip a leading line / block comment so the classifier doesn't
196 // misread `/* ... */ SELECT ...` as an unknown statement.
197 let trimmed = if let Some(rest) = trimmed.strip_prefix("--") {
198 rest.split_once('\n')
199 .map(|(_, r)| r)
200 .unwrap_or("")
201 .trim_start()
202 } else {
203 trimmed
204 };
205 let mut tokens = trimmed.split(|c: char| c.is_whitespace() || c == '(' || c == ';');
206 let first = tokens.next().unwrap_or("");
207 let second = tokens.next().unwrap_or("");
208 if first.eq_ignore_ascii_case("KV") {
209 if second.eq_ignore_ascii_case("GET")
210 || second.eq_ignore_ascii_case("LIST")
211 || second.eq_ignore_ascii_case("WATCH")
212 {
213 return "read";
214 }
215 return "write";
216 }
217 if first.eq_ignore_ascii_case("VAULT") {
218 if second.eq_ignore_ascii_case("LIST")
219 || second.eq_ignore_ascii_case("WATCH")
220 || second.eq_ignore_ascii_case("HISTORY")
221 {
222 return "read";
223 }
224 return "write";
225 }
226 // ASCII-uppercase compare without allocating: SQL keywords are ASCII.
227 let mut buf = [0u8; 16];
228 let bytes = first.as_bytes();
229 let n = bytes.len().min(buf.len());
230 for i in 0..n {
231 buf[i] = bytes[i].to_ascii_uppercase();
232 }
233 match &buf[..n] {
234 b"SELECT" | b"WITH" | b"SHOW" | b"EXPLAIN" | b"DESCRIBE" | b"DESC" | b"RANK"
235 | b"APPROX" | b"APPROXIMATE" | b"ZRANK" | b"ZRANGE" | b"LIST" | b"WATCH" | b"GET"
236 | b"HISTORY" => "read",
237 b"INSERT" | b"UPDATE" | b"DELETE" | b"UPSERT" | b"MERGE" | b"COPY" | b"TRUNCATE" => "write",
238 b"CREATE" | b"ALTER" | b"DROP" | b"PROMOTE" | b"REINDEX" | b"VACUUM" | b"ANALYZE" => "ddl",
239 b"GRANT" | b"REVOKE" => "admin",
240 b"BEGIN" | b"START" | b"COMMIT" | b"ROLLBACK" | b"SAVEPOINT" | b"RELEASE" | b"END"
241 | b"SET" | b"RESET" | b"PREPARE" | b"EXECUTE" | b"DEALLOCATE" | b"USE" => "control",
242 b"PUT" | b"INCR" | b"DECR" | b"ADD" | b"ROTATE" | b"PURGE" | b"UNSEAL" | b"INVALIDATE" => {
243 "write"
244 }
245 _ => "unknown",
246 }
247}
248
249/// Refuse a pre-parsed expression that carries a top-level `AS OF`.
250///
251/// `AS OF` is resolved from the statement text (`AsOfSpec` -> commit /
252/// branch / tag / timestamp -> xid) and `TableQuery::as_of` is consumed
253/// nowhere in the runtime or scan path. An expression-only frame
254/// therefore cannot honour it, and installing a live snapshot for a
255/// historical read would answer with current rows presented as history.
256/// Fail loudly instead; callers that have the text (`execute_query`,
257/// `execute_prepared_query`, `execute_query_with_params`) resolve it
258/// correctly. Mirrors `peek_top_level_as_of_with_table`, which likewise
259/// only recognises a top-level `AS OF`.
260fn reject_unresolvable_as_of(expr: &QueryExpr) -> RedDBResult<()> {
261 let QueryExpr::Table(table) = expr else {
262 return Ok(());
263 };
264 if table.as_of.is_none() {
265 return Ok(());
266 }
267 Err(RedDBError::InvalidOperation(
268 "AS OF cannot be resolved from a pre-parsed expression — \
269 execute the statement text (execute_query / execute_prepared_query / \
270 execute_query_with_params) so the historical snapshot is resolved"
271 .to_string(),
272 ))
273}
274
275fn classify_privilege(query: &str) -> Privilege {
276 match statement_kind(query) {
277 "read" => Privilege::Read,
278 "write" => Privilege::Write,
279 // DDL is gated at `Role::Write` in the legacy fallback (see
280 // `RedDBRuntime::check_query_privilege` for CreateTable et al.),
281 // so it classifies as Write here. APPLY / ROLLBACK MIGRATION and
282 // GRANT / REVOKE upgrade to Admin via finer checks at the call
283 // site — the frame surfaces only the coarse class.
284 "ddl" => Privilege::Write,
285 "admin" => Privilege::Admin,
286 _ => Privilege::None,
287 }
288}
289
290fn classify_lock_intent(query: &str) -> LockIntent {
291 match statement_kind(query) {
292 "read" => LockIntent::Shared,
293 "write" | "ddl" => LockIntent::Exclusive,
294 _ => LockIntent::None,
295 }
296}
297
298pub(super) struct StatementExecutionFrame {
299 tx_local_tenant: Option<Option<String>>,
300 snapshot: Snapshot,
301 own_xids: HashSet<Xid>,
302 serializable_reader: Option<Xid>,
303 cache_key: String,
304 is_volatile_query: bool,
305 cache_safe: bool,
306 /// Effective tenant captured at frame-build time after WITHIN /
307 /// SET LOCAL TENANT / SET TENANT resolution. Stored on the frame
308 /// so the `ReadFrame` Interface can return a borrow without
309 /// re-touching the thread-local stack.
310 effective_scope: Option<String>,
311 /// Auth identity captured at frame-build time. `None` for
312 /// embedded / anonymous callers.
313 identity: Option<(String, Role)>,
314 /// `Some(xid)` when AS OF resolved to a historical xid; `None`
315 /// for live reads.
316 as_of_floor: Option<Xid>,
317 /// True when the statement snapshot can require tuple versions that
318 /// current secondary indexes no longer contain.
319 requires_index_fallback: bool,
320 /// Privilege class required by the statement, derived from the
321 /// SQL text at frame-build time. Read/write dispatch sites
322 /// consult this instead of re-classifying the parsed expression.
323 required_privilege: Privilege,
324 /// Collection-level lock intent the statement implies. The
325 /// lock-acquisition path short-circuits when this is `None`.
326 lock_intent: LockIntent,
327 /// Set of collection ids the active `(tenant, role)` scope is
328 /// allowed to observe. Computed at frame-build time via the
329 /// `AuthStore` visibility cache and consumed by `AuthorizedSearch`
330 /// to gate SEARCH SIMILAR / SEARCH CONTEXT candidate sets before
331 /// scoring (issue #119). `None` when no auth store is wired
332 /// (embedded test mode) — AI search refuses on `None`.
333 visible_collections: Option<HashSet<String>>,
334 /// Per-owner buffer arena for query-result row chunks (#885). Owned
335 /// by the frame because the frame already owns the query lifecycle;
336 /// lent to the row-streaming path (`execute_runtime_table_query_in`)
337 /// so chunk buffers are reused across the statement's chunk-fetches
338 /// instead of allocated fresh per chunk. Reclaimed when the frame
339 /// drops at statement end — no `thread_local!` scratch, which would
340 /// be unsound under tokio's work-stealing runtime.
341 row_arena: Rc<RefCell<super::query_exec::RowBufferArena>>,
342}
343
344pub(super) struct StatementFrameGuards {
345 _tx_local_guard: TxLocalTenantGuard,
346 _config_snapshot_guard: ConfigSnapshotGuard,
347 _secret_store_guard: SecretStoreGuard,
348 _kv_store_guard: KvStoreGuard,
349 _snapshot_guard: CurrentSnapshotGuard,
350}
351
352pub(super) struct PreparedStatement {
353 pub(super) expr: QueryExpr,
354 pub(super) mode: QueryMode,
355}
356
357/// What a `StatementExecutionFrame` is built from.
358///
359/// The frame derives the statement snapshot (including any `AS OF`
360/// floor), the coarse privilege class, the lock intent, the volatility
361/// flag, and the result-cache key from its input. Feeding it anything
362/// other than the statement itself makes every one of those describe a
363/// different statement than the one that runs, so the input is named
364/// explicitly here rather than being "some string".
365#[derive(Clone, Copy)]
366pub(super) enum StatementIdentity<'a> {
367 /// The statement's SQL text. All five derivations are exact.
368 Text(&'a str),
369 /// A pre-parsed expression with no accompanying text — the nested
370 /// in-statement callers (CTE prelude, materialized-view refresh,
371 /// inline graph TVF) hold an expression the outer statement already
372 /// parsed.
373 ///
374 /// Text-derived facts are not guessed from a kind label: there is no
375 /// stable cache key (a label would collide across every statement of
376 /// the same kind), and the coarse privilege / lock short-circuits are
377 /// left at `None` so the deep grant engine stays the gate. `AS OF` is
378 /// rejected up front by `build` because it cannot be resolved without
379 /// the text.
380 Expr(&'a QueryExpr),
381}
382
383impl<'a> StatementIdentity<'a> {
384 /// SQL text when the frame was built from text, `""` otherwise. Used
385 /// for the text-derived classifications, all of which degrade to the
386 /// deliberately inert `unknown` / `None` classes on the expr path.
387 fn text(&self) -> &'a str {
388 match self {
389 Self::Text(query) => query,
390 Self::Expr(_) => "",
391 }
392 }
393}
394
395impl StatementExecutionFrame {
396 pub(super) fn build(
397 runtime: &RedDBRuntime,
398 identity_source: StatementIdentity<'_>,
399 ) -> RedDBResult<Self> {
400 let query = identity_source.text();
401 if let StatementIdentity::Expr(expr) = identity_source {
402 reject_unresolvable_as_of(expr)?;
403 }
404 let conn_id = current_connection_id();
405 let tx_local_tenant = runtime.inner.transaction_state.local_tenant(conn_id);
406 let tx_context = runtime.inner.transaction_state.context(conn_id);
407 let own_xids = own_transaction_xids(tx_context.as_ref());
408 let serializable_reader = tx_context
409 .as_ref()
410 .filter(|ctx| {
411 ctx.isolation == crate::storage::transaction::IsolationLevel::Serializable
412 })
413 .map(|ctx| ctx.xid);
414 let (snapshot, as_of_floor) = runtime.statement_snapshot(query, identity_source)?;
415 let requires_index_fallback = as_of_floor.is_some() || tx_context.is_some();
416 let (cache_key, is_volatile_query) = match identity_source {
417 StatementIdentity::Text(_) => (
418 result_cache_key(query),
419 query_has_volatile_builtin(query) || query_is_ask_statement(query),
420 ),
421 // No text means no stable key, so the frame opts out of the
422 // result cache on both sides: an empty key is never read from
423 // or written to, and `is_volatile_query` is set so
424 // `should_cache_result` refuses regardless of the key.
425 StatementIdentity::Expr(_) => (String::new(), true),
426 };
427 let cache_safe = runtime.result_cache_safe(conn_id);
428 // Capture identity + effective scope under the same
429 // thread-local view that the cache key was built from, so
430 // the Interface and the cache key agree on what "this
431 // statement" means.
432 let effective_scope = current_tenant();
433 let identity = current_auth_identity();
434
435 // Coarse classification of the statement, computed once from
436 // the SQL prefix so downstream callers don't re-derive it
437 // from the parsed `QueryExpr` at every privilege / lock site.
438 // On the expr path `query` is empty, so both classify as the
439 // inert `None` class: the coarse gate can only deny, never
440 // allow (see `check_query_privilege`), so declining to
441 // short-circuit leaves the deep grant engine as the gate.
442 let required_privilege = classify_privilege(query);
443 let lock_intent = classify_lock_intent(query);
444
445 // Issue #119: resolve the visible-collections set for the
446 // active (tenant, role) scope. Only meaningful when an auth
447 // store is wired *and* an identity was captured — embedded
448 // anonymous callers fall back to `None`, and AI search call
449 // sites refuse on `None`.
450 let visible_collections = match (runtime.inner.auth_store.read().clone(), identity.as_ref())
451 {
452 (Some(store), Some((principal, role))) => {
453 let collections = runtime.inner.db.store().list_collections();
454 Some(store.visible_collections_for_scope(
455 effective_scope.as_deref(),
456 *role,
457 principal,
458 &collections,
459 ))
460 }
461 _ => None,
462 };
463
464 Ok(Self {
465 tx_local_tenant,
466 snapshot,
467 own_xids,
468 serializable_reader,
469 cache_key,
470 is_volatile_query,
471 cache_safe,
472 effective_scope,
473 identity,
474 as_of_floor,
475 requires_index_fallback,
476 required_privilege,
477 lock_intent,
478 visible_collections,
479 row_arena: Rc::new(RefCell::new(super::query_exec::RowBufferArena::new())),
480 })
481 }
482
483 /// Lend the frame's per-owner row-buffer arena (#885) to the
484 /// row-streaming path. Returns a cloned `Rc` handle; the frame remains
485 /// the owner and the arena is reclaimed when the frame drops at
486 /// statement end.
487 pub(super) fn row_arena(&self) -> Rc<RefCell<super::query_exec::RowBufferArena>> {
488 Rc::clone(&self.row_arena)
489 }
490
491 pub(super) fn install(&self, runtime: &RedDBRuntime) -> StatementFrameGuards {
492 StatementFrameGuards {
493 _tx_local_guard: TxLocalTenantGuard::install(self.tx_local_tenant.clone()),
494 _config_snapshot_guard: ConfigSnapshotGuard::install(
495 Arc::clone(&runtime.inner.db),
496 runtime.inner.auth_store.read().clone(),
497 ),
498 _secret_store_guard: SecretStoreGuard::install(runtime.inner.auth_store.read().clone()),
499 _kv_store_guard: KvStoreGuard::install(runtime.inner.auth_store.read().clone()),
500 _snapshot_guard: CurrentSnapshotGuard::install(SnapshotContext {
501 snapshot: self.snapshot.clone(),
502 manager: runtime.inner.transaction_state.snapshot_manager(),
503 own_xids: self.own_xids.clone(),
504 requires_index_fallback: self.requires_index_fallback,
505 serializable_reader: self.serializable_reader,
506 }),
507 }
508 }
509
510 pub(super) fn cache_key(&self) -> &str {
511 &self.cache_key
512 }
513
514 pub(super) fn can_read_result_cache(&self) -> bool {
515 // Delegates to the `ReadFrame` Interface so the volatile +
516 // active-tx safety decision lives in exactly one place.
517 <Self as ReadFrame>::should_cache_result(self)
518 }
519
520 pub(super) fn should_write_result_cache(&self, result: &RuntimeQueryResult) -> bool {
521 // Cache-safety (volatile builtin, active-tx writes) comes from
522 // the Interface; the rest are write-side payload heuristics
523 // (statement shape, result size) that aren't part of the
524 // safety contract.
525 <Self as ReadFrame>::should_cache_result(self)
526 && result.statement_type == "select"
527 && result.engine != "vault"
528 && result.engine != "runtime-rank"
529 // `QUEUE READ` is a stateful read: a delayed message
530 // (issue #722) becomes deliverable over time without a
531 // producer push to invalidate the cache, so a cached empty
532 // result would hide it. Skip caching entirely.
533 && result.statement != "queue_group_read"
534 // SCRUB is a stateful read too: the background form advances
535 // a pacing cursor per tick (a cached tick would replay the
536 // summary and never advance), and even a full pass must
537 // re-read the file to see new corruption. Never cache.
538 && result.engine != "runtime-scrub"
539 && result.result.pre_serialized_json.is_none()
540 // Graph-analytics TVF output (issue #802) is deterministic and
541 // expensive to recompute, so it is cached at any row count. The
542 // ≤5-row heuristic only bounds payload size for ordinary SELECTs.
543 && (is_graph_tvf_engine(result.engine) || result.result.records.len() <= 5)
544 }
545
546 pub(super) fn read_result_cache(&self, runtime: &RedDBRuntime) -> Option<RuntimeQueryResult> {
547 if self.can_read_result_cache() {
548 runtime
549 .get_result_cache_entry(self.cache_key())
550 .map(|mut result| {
551 if let Some(vector) = result.result.stats.vector.as_mut() {
552 vector.cache_hit = true;
553 }
554 result
555 })
556 } else {
557 None
558 }
559 }
560
561 pub(super) fn write_result_cache(
562 &self,
563 runtime: &RedDBRuntime,
564 result: &RuntimeQueryResult,
565 scopes: HashSet<String>,
566 ) {
567 if self.should_write_result_cache(result) {
568 runtime.put_result_cache_entry(
569 self.cache_key(),
570 RuntimeResultCacheEntry {
571 result: result.clone(),
572 cached_at: std::time::Instant::now(),
573 scopes,
574 },
575 );
576 }
577 }
578
579 pub(super) fn prepare_cte(&self, query: &str) -> RedDBResult<Option<QueryExpr>> {
580 // Detected via cheap prefix check so non-CTE queries skip the
581 // full parse here. CTE-bearing queries bypass the plan cache
582 // and result cache (rare workload — perf optimization is a
583 // follow-up). Inlining substitutes every CTE reference with
584 // its body as a subquery in FROM, after which the existing
585 // subquery-in-FROM machinery handles execution. Recursive
586 // CTEs are rejected explicitly until fixpoint execution wires
587 // through the runtime.
588 if !has_with_prefix(query) {
589 return Ok(None);
590 }
591 let parsed =
592 reddb_rql::parser::parse(query).map_err(|err| RedDBError::Query(err.to_string()))?;
593 if parsed.with_clause.is_some() {
594 let rewritten = crate::storage::query::executors::inline_ctes(parsed)
595 .map_err(|err| RedDBError::Query(err.to_string()))?;
596 return Ok(Some(rewritten));
597 }
598 // No WITH after parse (the prefix matched something else like
599 // `WITHIN` that already routed elsewhere) — fall through to
600 // the normal path with the original query.
601 Ok(None)
602 }
603
604 pub(super) fn prepare_statement(
605 &self,
606 runtime: &RedDBRuntime,
607 query: &str,
608 ) -> RedDBResult<PreparedStatement> {
609 let mode = detect_mode(query);
610 if matches!(mode, QueryMode::Unknown) {
611 return Err(RedDBError::Query("unable to detect query mode".to_string()));
612 }
613
614 // ── Plan cache: reuse only exact-query ASTs ──
615 //
616 // DML statements (INSERT/UPDATE/DELETE) almost always have unique literal
617 // values, so caching them burns CPU on eviction bookkeeping (Vec::remove(0)
618 // shifts the entire LRU list) with zero hit rate. Skip the cache entirely
619 // Plan cache applies to statements whose shape can be
620 // normalised + rebound (`UPDATE t SET x=? WHERE _entity_id=?`
621 // reuses the same plan across thousands of varying literals).
622 // INSERT is still bypassed — its shape changes per column set
623 // and bulk paths don't go through here anyway.
624 let first_word = query
625 .trim()
626 .split_ascii_whitespace()
627 .next()
628 .unwrap_or("")
629 .to_ascii_uppercase();
630 let is_insert = first_word == "INSERT";
631 // #1370 — volatile queries ($config / $secret resolve mutable runtime
632 // state at execution time) must bypass the plan cache too. A cached
633 // optimized plan drops the live `$config` resolution, so a later
634 // `SET CONFIG` would be ignored and the query would serve a stale value.
635 // Re-parse fresh every time so the resolver runs against current state.
636 let bypass_plan_cache = is_insert || self.is_volatile_query;
637
638 // Fused normalize+extract: one byte-scan produces both the
639 // cache_key AND the literal bindings. Saves a second Lexer
640 // pass over the query text on every cache hit — dominant
641 // cost on tight UPDATE loops that hit the same shape
642 // thousands of times with varying literals.
643 let (cache_key, prescan_binds) = if bypass_plan_cache {
644 (String::new(), Vec::new())
645 } else {
646 crate::storage::query::planner::cache_key::normalize_and_extract(query)
647 };
648
649 let expr = if bypass_plan_cache {
650 // Bypass plan cache for INSERT — shape varies per query.
651 parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
652 } else {
653 // ── Hot path: read lock only (no writer serialization on cache hits) ──
654 //
655 // peek() is a non-mutating probe: no LRU promotion, no touch().
656 // This lets concurrent readers proceed without blocking each other.
657 // On hit we bind literals if needed and return immediately.
658 // Only on miss do we drop to a write lock to parse + insert.
659 let hit = {
660 let plan_cache = runtime.inner.query_cache.read();
661 plan_cache.peek(&cache_key).map(|cached| {
662 let parameter_count = cached.parameter_count;
663 let optimized = cached.plan.optimized.clone();
664 let exact_query = cached.exact_query.clone();
665 (parameter_count, optimized, exact_query)
666 })
667 };
668
669 if let Some((parameter_count, optimized, exact_query)) = hit {
670 if parameter_count > 0 {
671 // Shape hit: use the binds extracted during normalise.
672 let shape_binds = prescan_binds.clone();
673 if let Some(bound) =
674 crate::storage::query::planner::shape::bind_parameterized_query(
675 &optimized,
676 &shape_binds,
677 parameter_count,
678 )
679 {
680 bound
681 } else if exact_query.as_deref() == Some(query) {
682 // Bind failed but exact query matches — use as-is.
683 optimized
684 } else {
685 // Bind failed and literals differ: re-parse fresh.
686 parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
687 }
688 } else {
689 // No parameters means either there truly are no literals,
690 // or this statement type does not participate in shape
691 // parameterization (for example graph/queue commands).
692 // Reusing a normalized-cache hit across a different exact
693 // query can therefore leak stale literals into execution.
694 if exact_query.as_deref() == Some(query) {
695 optimized
696 } else {
697 parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
698 }
699 }
700 } else {
701 // Cache miss — parse, parameterize, store.
702 let parsed =
703 parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
704 let (cached_expr, parameter_count) = if let Some(prepared) =
705 crate::storage::query::planner::shape::parameterize_query_expr(&parsed)
706 {
707 (prepared.shape, prepared.parameter_count)
708 } else {
709 (parsed.clone(), 0)
710 };
711 {
712 let mut pc = runtime.inner.query_cache.write();
713 let plan = crate::storage::query::planner::QueryPlan::new(
714 parsed.clone(),
715 cached_expr,
716 Default::default(),
717 );
718 pc.insert(
719 cache_key.clone(),
720 crate::storage::query::planner::CachedPlan::new(plan)
721 .with_shape_key(cache_key.clone())
722 .with_exact_query(query.to_string())
723 .with_parameter_count(parameter_count),
724 );
725 }
726 parsed
727 }
728 };
729
730 // Phase 5 PG parity: substitute any registered view name that
731 // appears in the expression with its stored body. Runs after
732 // parse and before dispatch so the SQL entrypoint gets the
733 // same view resolution `execute_query_expr` already does.
734 let expr = runtime.rewrite_view_refs(expr);
735
736 Ok(PreparedStatement { expr, mode })
737 }
738
739 pub(super) fn check_query_privilege(
740 &self,
741 runtime: &RedDBRuntime,
742 expr: &QueryExpr,
743 ) -> RedDBResult<()> {
744 // Frame-level coarse gate. We consult `required_privilege()`
745 // (computed once at frame-build) against the captured identity
746 // before the deep grant engine walks the parsed expression.
747 // The coarse gate cannot ALLOW anything the grant engine would
748 // deny — it only short-circuits the obvious "Role::Read tries
749 // INSERT" case so a downstream caller never has to redo this
750 // check inline. `Privilege::None` (transaction control / SET /
751 // SHOW) flows through unchanged; the grant engine treats those
752 // as bypass too.
753 if let Some((username, role)) = <Self as ReadFrame>::identity(self) {
754 let needed = <Self as ReadFrame>::required_privilege(self);
755 if !needed.is_satisfied_by(role) {
756 // Issue #205 — when the deep grant engine *also*
757 // denies, we treat this as an ordinary permission
758 // failure. But when an Admin-only statement reaches
759 // this gate without an auth_store wired (so the deep
760 // engine can't double-check), the coarse rejection is
761 // the only line of defence — emit an OperatorEvent so
762 // the operator notices an Admin-class statement was
763 // attempted with insufficient role.
764 if matches!(needed, Privilege::Admin) && runtime.inner.auth_store.read().is_none() {
765 crate::telemetry::operator_event::OperatorEvent::AuthBypass {
766 principal: username.to_string(),
767 resource: format!("statement requiring {needed:?}"),
768 detail: format!(
769 "auth_store not wired; coarse gate is sole defence (role={role:?})"
770 ),
771 }
772 .emit_global();
773 }
774 return Err(RedDBError::Query(format!(
775 "permission denied: principal=`{username}` role=`{role:?}` lacks {needed:?} privilege"
776 )));
777 }
778 }
779 runtime
780 .check_query_privilege(expr)
781 .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))
782 }
783
784 pub(super) fn prepare_dispatch(
785 &self,
786 runtime: &RedDBRuntime,
787 expr: &QueryExpr,
788 ) -> RedDBResult<Option<crate::runtime::locking::LockerGuard>> {
789 runtime.validate_model_operations_before_auth(expr)?;
790 self.check_query_privilege(runtime, expr)?;
791 Ok(self.acquire_intent_locks(runtime, expr))
792 }
793
794 pub(super) fn acquire_intent_locks(
795 &self,
796 runtime: &RedDBRuntime,
797 expr: &QueryExpr,
798 ) -> Option<crate::runtime::locking::LockerGuard> {
799 if !runtime.config_bool("concurrency.locking.enabled", true) {
800 return None;
801 }
802 // Frame-level short-circuit: if the statement carries no lock
803 // intent (transaction control, SET, SHOW), skip the lock
804 // manager entirely instead of letting `intent_lock_modes_for`
805 // walk the parsed expression to reach the same conclusion.
806 if matches!(<Self as ReadFrame>::lock_intent(self), LockIntent::None) {
807 return None;
808 }
809 intent_lock_modes_for(expr).map(|(global_mode, coll_mode)| {
810 let mut guard =
811 crate::runtime::locking::LockerGuard::new(runtime.inner.lock_manager.clone());
812 let _ = guard.acquire(crate::runtime::locking::Resource::Global, global_mode);
813 for collection in collections_referenced(expr) {
814 let _ = guard.acquire(
815 crate::runtime::locking::Resource::Collection(collection),
816 coll_mode,
817 );
818 }
819 guard
820 })
821 }
822}
823
824impl ReadFrame for StatementExecutionFrame {
825 fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
826 SnapshotContext {
827 snapshot: self.snapshot.clone(),
828 manager: runtime.inner.transaction_state.snapshot_manager(),
829 own_xids: self.own_xids.clone(),
830 requires_index_fallback: self.requires_index_fallback,
831 serializable_reader: self.serializable_reader,
832 }
833 }
834
835 fn effective_scope(&self) -> Option<&str> {
836 self.effective_scope.as_deref()
837 }
838
839 fn identity(&self) -> Option<(&str, Role)> {
840 self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
841 }
842
843 fn snapshot(&self) -> &Snapshot {
844 &self.snapshot
845 }
846
847 fn as_of_floor(&self) -> Option<Xid> {
848 self.as_of_floor
849 }
850
851 fn cache_key(&self) -> &str {
852 &self.cache_key
853 }
854
855 fn should_cache_result(&self) -> bool {
856 !self.is_volatile_query && self.cache_safe
857 }
858
859 fn required_privilege(&self) -> Privilege {
860 self.required_privilege
861 }
862
863 fn lock_intent(&self) -> LockIntent {
864 self.lock_intent
865 }
866
867 fn visible_collections(&self) -> Option<&HashSet<String>> {
868 self.visible_collections.as_ref()
869 }
870}
871
872/// Lightweight `ReadFrame` carrier used by AI command entry points
873/// (`SEARCH SIMILAR`, `SEARCH CONTEXT`, `ASK`).
874///
875/// Issue #119 calls this struct `EffectiveScope`. It bundles the
876/// `(tenant, identity, role, visible_collections, snapshot)` tuple so
877/// every AI runtime entry can pass *one* value to `AuthorizedSearch`
878/// instead of re-reading thread-locals at every call site.
879///
880/// Built via `RedDBRuntime::ai_scope()` which sources tenant + identity
881/// from the per-statement thread-locals (identical to how
882/// `StatementExecutionFrame::build` derives them) and resolves
883/// `visible_collections` via the `AuthStore` cache.
884pub struct EffectiveScope {
885 pub(crate) captured_snapshot: Option<SnapshotContext>,
886 pub(crate) tenant: Option<String>,
887 pub(crate) identity: Option<(String, Role)>,
888 pub(crate) snapshot: Snapshot,
889 pub(crate) visible_collections: Option<HashSet<String>>,
890}
891
892impl EffectiveScope {
893 /// Capability check used by the AI runtime (`runtime/ai/ner.rs`)
894 /// to gate LLM-backed NER calls behind `ai:ner:read`.
895 ///
896 /// Placeholder for now: always returns `false`. The auth engine's
897 /// capability matrix is future work; until it lands, every routed
898 /// LLM-NER call denies at the gate and `extract_tokens_routed`'s
899 /// heuristic fallback fires (see `ask_pipeline::extract_tokens_routed`).
900 /// Documented in code so the wire-up is a one-line change once
901 /// the auth engine learns capabilities.
902 pub fn has_capability(&self, _capability: &str) -> bool {
903 false
904 }
905}
906
907impl ReadFrame for EffectiveScope {
908 fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
909 self.captured_snapshot
910 .clone()
911 .unwrap_or_else(|| SnapshotContext {
912 snapshot: self.snapshot.clone(),
913 manager: runtime.inner.transaction_state.snapshot_manager(),
914 own_xids: HashSet::new(),
915 requires_index_fallback: true,
916 serializable_reader: None,
917 })
918 }
919
920 fn effective_scope(&self) -> Option<&str> {
921 self.tenant.as_deref()
922 }
923 fn identity(&self) -> Option<(&str, Role)> {
924 self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
925 }
926 fn snapshot(&self) -> &Snapshot {
927 &self.snapshot
928 }
929 fn as_of_floor(&self) -> Option<Xid> {
930 None
931 }
932 fn cache_key(&self) -> &str {
933 ""
934 }
935 fn should_cache_result(&self) -> bool {
936 false
937 }
938 fn required_privilege(&self) -> Privilege {
939 Privilege::Read
940 }
941 fn lock_intent(&self) -> LockIntent {
942 LockIntent::Shared
943 }
944 fn visible_collections(&self) -> Option<&HashSet<String>> {
945 self.visible_collections.as_ref()
946 }
947}
948
949fn own_transaction_xids(
950 ctx: Option<&crate::storage::transaction::snapshot::TxnContext>,
951) -> HashSet<Xid> {
952 let mut set = HashSet::new();
953 if let Some(ctx) = ctx {
954 set.insert(ctx.xid);
955 for (_, sub) in &ctx.savepoints {
956 set.insert(*sub);
957 }
958 for sub in &ctx.released_sub_xids {
959 set.insert(*sub);
960 }
961 }
962 set
963}
964
965impl RedDBRuntime {
966 /// Build the AI command `EffectiveScope` from the current
967 /// statement thread-locals + auth store.
968 ///
969 /// Returns `None` for embedded callers (no auth store, no
970 /// identity) — `AuthorizedSearch` treats `None` as deny-default.
971 pub(crate) fn ai_scope(&self) -> EffectiveScope {
972 let tenant = super::impl_core::current_tenant();
973 let identity = super::impl_core::current_auth_identity();
974 let captured_snapshot = super::execution_context::capture_current_snapshot();
975 let snapshot = captured_snapshot.as_ref().map_or_else(
976 || self.current_snapshot(),
977 |context| context.snapshot.clone(),
978 );
979 let visible_collections = match (self.inner.auth_store.read().clone(), identity.as_ref()) {
980 (Some(store), Some((principal, role))) => {
981 let collections = self.inner.db.store().list_collections();
982 Some(store.visible_collections_for_scope(
983 tenant.as_deref(),
984 *role,
985 principal,
986 &collections,
987 ))
988 }
989 _ => None,
990 };
991 EffectiveScope {
992 captured_snapshot,
993 tenant,
994 identity,
995 snapshot,
996 visible_collections,
997 }
998 }
999}
1000
1001/// Test fixtures for callers that need to drive `ReadFrame` without
1002/// booting a runtime. Lives behind `cfg(test)` and `pub(crate)` so it
1003/// only leaks across module boundaries inside the crate.
1004#[cfg(test)]
1005pub(crate) mod test_support {
1006 use super::{LockIntent, Privilege, ReadFrame};
1007 use crate::auth::Role;
1008 use crate::storage::transaction::snapshot::{Snapshot, Xid};
1009 use std::collections::HashSet;
1010
1011 /// A `ReadFrame` impl with hand-set fields. Used by
1012 /// `authorized_search` tests to assert the deny-default and
1013 /// scope-trim behaviour without going through frame construction.
1014 pub(crate) struct FakeReadFrame {
1015 pub tenant: Option<String>,
1016 pub identity: Option<(String, Role)>,
1017 pub snapshot: Snapshot,
1018 pub visible: Option<HashSet<String>>,
1019 }
1020
1021 impl FakeReadFrame {
1022 pub(crate) fn without_scope() -> Self {
1023 Self {
1024 tenant: None,
1025 identity: None,
1026 snapshot: Snapshot {
1027 xid: 0,
1028 in_progress: HashSet::new(),
1029 },
1030 visible: None,
1031 }
1032 }
1033
1034 pub(crate) fn with_visible(visible: HashSet<String>) -> Self {
1035 Self {
1036 tenant: Some("acme".to_string()),
1037 identity: Some(("alice".to_string(), Role::Read)),
1038 snapshot: Snapshot {
1039 xid: 0,
1040 in_progress: HashSet::new(),
1041 },
1042 visible: Some(visible),
1043 }
1044 }
1045 }
1046
1047 impl ReadFrame for FakeReadFrame {
1048 fn effective_scope(&self) -> Option<&str> {
1049 self.tenant.as_deref()
1050 }
1051 fn identity(&self) -> Option<(&str, Role)> {
1052 self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
1053 }
1054 fn snapshot(&self) -> &Snapshot {
1055 &self.snapshot
1056 }
1057 fn as_of_floor(&self) -> Option<Xid> {
1058 None
1059 }
1060 fn cache_key(&self) -> &str {
1061 ""
1062 }
1063 fn should_cache_result(&self) -> bool {
1064 false
1065 }
1066 fn required_privilege(&self) -> Privilege {
1067 Privilege::Read
1068 }
1069 fn lock_intent(&self) -> LockIntent {
1070 LockIntent::Shared
1071 }
1072 fn visible_collections(&self) -> Option<&HashSet<String>> {
1073 self.visible.as_ref()
1074 }
1075 }
1076}
1077
1078impl RedDBRuntime {
1079 /// Resolve the snapshot for the current statement, returning
1080 /// the snapshot itself and (when AS OF is in effect) the
1081 /// resolved xid floor. The floor is the same xid carried inside
1082 /// `Snapshot.xid` for AS OF reads — exposing it separately lets
1083 /// the `ReadFrame` Interface tell "live read" from "historical
1084 /// read" without inferring from `in_progress.is_empty()`.
1085 ///
1086 /// A live read nested inside an already-installed frame inherits that
1087 /// frame's snapshot instead of minting a fresh one — but only when it
1088 /// is genuinely *part of* that statement, which is exactly what
1089 /// [`StatementIdentity::Expr`] means. A CTE prelude, a
1090 /// materialized-view refresh, or an inline graph TVF is one statement
1091 /// and must observe one snapshot; re-minting under autocommit / Read
1092 /// Committed takes a new high-water mark, so the nodes and edges of a
1093 /// single graph TVF could otherwise be materialized under two
1094 /// different snapshots.
1095 ///
1096 /// A nested `Text` statement is a different thing entirely: a driver
1097 /// loop issuing statement after statement through `execute_query`.
1098 /// Those are sequential autocommit statements and each must see its
1099 /// predecessors' commits. Inheriting there froze the loop's view —
1100 /// `apply_batched` (`impl_migrations.rs`) drives `APPLY MIGRATION …
1101 /// BATCH N ROWS` by re-running the body with `LIMIT N` until fewer
1102 /// than `N` rows are affected, so a frozen snapshot re-selected the
1103 /// same rows forever and the migration never converged. Twenty-five
1104 /// rows at `BATCH 7` should finish in four iterations; it hung
1105 /// unbounded instead.
1106 ///
1107 /// `AS OF` still wins over both — it names its own snapshot explicitly.
1108 fn statement_snapshot(
1109 &self,
1110 query: &str,
1111 identity: StatementIdentity<'_>,
1112 ) -> RedDBResult<(Snapshot, Option<Xid>)> {
1113 match peek_top_level_as_of_with_table(query) {
1114 Some((spec, Some(table))) => {
1115 if !table.starts_with("red_") && !self.vcs_is_versioned(&table)? {
1116 return Err(RedDBError::InvalidConfig(format!(
1117 "AS OF requires a versioned collection — \
1118 `{table}` has not opted in. \
1119 Call vcs.set_versioned(\"{table}\", true) first."
1120 )));
1121 }
1122 let xid = self.vcs_resolve_as_of(spec)?;
1123 Ok((
1124 Snapshot {
1125 xid,
1126 in_progress: HashSet::new(),
1127 },
1128 Some(xid),
1129 ))
1130 }
1131 Some((spec, None)) => {
1132 let xid = self.vcs_resolve_as_of(spec)?;
1133 Ok((
1134 Snapshot {
1135 xid,
1136 in_progress: HashSet::new(),
1137 },
1138 Some(xid),
1139 ))
1140 }
1141 None => match (identity, capture_current_snapshot()) {
1142 (StatementIdentity::Expr(_), Some(outer)) => Ok((outer.snapshot, None)),
1143 _ => Ok((self.current_snapshot(), None)),
1144 },
1145 }
1146 }
1147
1148 fn result_cache_safe(&self, conn_id: u64) -> bool {
1149 let has_active_xids = self
1150 .inner
1151 .transaction_state
1152 .snapshot_manager()
1153 .oldest_active_xid()
1154 .is_some();
1155 let in_own_tx = self.inner.transaction_state.in_transaction(conn_id);
1156 !has_active_xids && !in_own_tx
1157 }
1158}
1159
1160/// Whether a result's `engine` tag is one of the graph-analytics TVF
1161/// executors (issue #802). Graph-collection (`louvain(g)`) and inline
1162/// (`louvain(nodes => …, edges => …)`) forms both produce deterministic
1163/// algorithm output that is cached regardless of row count.
1164fn is_graph_tvf_engine(engine: &str) -> bool {
1165 matches!(engine, "runtime-graph-tvf" | "runtime-graph-tvf-inline")
1166}
1167
1168fn result_cache_key(query: &str) -> String {
1169 let tenant = current_tenant().unwrap_or_default();
1170 let auth = current_auth_identity()
1171 .map(|(user, role)| format!("{}|{:?}", user, role))
1172 .unwrap_or_default();
1173 if tenant.is_empty() && auth.is_empty() {
1174 query.to_string()
1175 } else {
1176 format!("{query}\u{001e}{tenant}\u{001e}{auth}")
1177 }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183 use crate::api::RedDBOptions;
1184 use crate::runtime::impl_core::{
1185 clear_current_auth_identity, clear_current_tenant, set_current_auth_identity,
1186 set_current_tenant,
1187 };
1188 use crate::runtime::RedDBRuntime;
1189
1190 fn fresh_runtime() -> RedDBRuntime {
1191 RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("in-memory runtime")
1192 }
1193
1194 /// Ensure thread-local state from a prior test can't leak into
1195 /// the next one — tests in the same binary share the thread.
1196 fn reset_thread_locals() {
1197 clear_current_tenant();
1198 clear_current_auth_identity();
1199 }
1200
1201 #[test]
1202 fn autocommit_select_takes_live_snapshot() {
1203 reset_thread_locals();
1204 let rt = fresh_runtime();
1205 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1206 .expect("frame builds for SELECT 1");
1207
1208 // The inclusive bound excludes the next unallocated writer xid.
1209 // A pristine manager therefore has a valid empty snapshot at zero.
1210 let f: &dyn ReadFrame = &frame;
1211 assert!(f.as_of_floor().is_none(), "live read has no AS OF floor");
1212 assert_eq!(f.snapshot().xid, rt.snapshot_manager().peek_next_xid() - 1);
1213 assert!(!f.snapshot().sees(rt.snapshot_manager().peek_next_xid(), 0));
1214 }
1215
1216 #[test]
1217 fn frame_captures_identity_and_scope() {
1218 reset_thread_locals();
1219 set_current_tenant("acme".to_string());
1220 set_current_auth_identity("alice".to_string(), Role::Write);
1221
1222 let rt = fresh_runtime();
1223 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1224 .expect("frame builds");
1225 let f: &dyn ReadFrame = &frame;
1226
1227 assert_eq!(f.effective_scope(), Some("acme"));
1228 let id = f.identity().expect("identity captured");
1229 assert_eq!(id.0, "alice");
1230 assert!(matches!(id.1, Role::Write));
1231
1232 // Cache key mixes scope + identity so two callers under
1233 // different tenants never share a cache slot.
1234 assert!(
1235 f.cache_key().contains("acme") && f.cache_key().contains("alice"),
1236 "cache key folds in scope + identity, got {:?}",
1237 f.cache_key()
1238 );
1239
1240 reset_thread_locals();
1241 }
1242
1243 #[test]
1244 fn as_of_rejects_non_versioned_user_collection() {
1245 reset_thread_locals();
1246 let rt = fresh_runtime();
1247
1248 // `not_versioned` is a plain user collection — the frame
1249 // builder must reject AS OF until the caller opts in via
1250 // `vcs.set_versioned`.
1251 let err = match StatementExecutionFrame::build(
1252 &rt,
1253 StatementIdentity::Text("SELECT * FROM not_versioned AS OF COMMIT 'deadbeef'"),
1254 ) {
1255 Err(e) => e,
1256 Ok(_) => panic!("AS OF on non-versioned user collection rejected"),
1257 };
1258
1259 let msg = format!("{err}");
1260 assert!(
1261 msg.contains("AS OF requires a versioned collection"),
1262 "expected AS OF rejection, got: {msg}"
1263 );
1264 }
1265
1266 /// End-to-end proof that the SELECT path consumes a `ReadFrame`.
1267 ///
1268 /// Sets a tenant + identity via the public thread-local API the
1269 /// runtime uses for ambient scope, drives a real `SELECT` through
1270 /// `execute_query`, then inspects the result cache that the SELECT
1271 /// path populates via `frame.cache_key()`. The key only carries
1272 /// the tenant + identity *because* it was built through the frame —
1273 /// reverting the wiring to inline `current_tenant()` /
1274 /// `current_auth_identity()` reads would still pass this test, but
1275 /// dropping the frame entirely (so the SELECT path stopped touching
1276 /// `cache_key`) would break it.
1277 #[test]
1278 fn select_path_routes_through_frame_cache_key() {
1279 reset_thread_locals();
1280 set_current_tenant("acme".to_string());
1281 set_current_auth_identity("alice".to_string(), Role::Read);
1282
1283 let rt = fresh_runtime();
1284 let result = rt
1285 .execute_query("SELECT 1")
1286 .expect("SELECT 1 executes under tenant=acme/identity=alice");
1287 assert_eq!(result.statement_type, "select");
1288
1289 // The textual SELECT path builds a frame and
1290 // writes its result through `frame.cache_key()`. That key folds
1291 // tenant + identity in via `result_cache_key`, so finding "acme"
1292 // and "alice" inside any cached key proves the frame was the
1293 // seam used.
1294 let cache = rt.inner.result_cache.read();
1295 let any_keyed_with_scope = cache
1296 .0
1297 .keys()
1298 .any(|k| k.contains("acme") && k.contains("alice"));
1299 assert!(
1300 any_keyed_with_scope,
1301 "expected at least one result-cache key carrying tenant+identity, \
1302 got keys: {:?}",
1303 cache.0.keys().collect::<Vec<_>>()
1304 );
1305
1306 reset_thread_locals();
1307 }
1308
1309 /// A SELECT that calls a volatile builtin (here:
1310 /// `pg_advisory_unlock`, the volatile token the runtime currently
1311 /// recognises in `query_has_volatile_builtin`) must NOT populate
1312 /// the result cache. Any caller hitting the cache after this would
1313 /// see a stale answer for an inherently-volatile query, so the
1314 /// SELECT path gates writes through `frame.should_cache_result()`.
1315 ///
1316 /// Deletion test: removing `ReadFrame::should_cache_result`, or
1317 /// reverting the SELECT path to skip its safety gate, would let
1318 /// the result cache silently absorb this statement and break the
1319 /// assertion below.
1320 #[test]
1321 fn volatile_select_does_not_populate_result_cache() {
1322 reset_thread_locals();
1323 let rt = fresh_runtime();
1324
1325 // Frame-level invariant: the volatile-builtin signal collapses
1326 // `should_cache_result` to false even for an autocommit /
1327 // out-of-tx connection.
1328 let frame = StatementExecutionFrame::build(
1329 &rt,
1330 StatementIdentity::Text("SELECT pg_advisory_unlock(1)"),
1331 )
1332 .expect("frame");
1333 let f: &dyn ReadFrame = &frame;
1334 assert!(
1335 !f.should_cache_result(),
1336 "volatile builtin must disable result-cache safety"
1337 );
1338
1339 // End-to-end: drive the volatile SELECT through `execute_query`
1340 // and confirm no entry was stamped under its cache key. Other
1341 // entries from prior tests sharing the binary may exist, so we
1342 // assert specifically on this query's key.
1343 let _ = rt
1344 .execute_query("SELECT pg_advisory_unlock(1)")
1345 .expect("volatile SELECT executes");
1346 let cache = rt.inner.result_cache.read();
1347 let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1348 assert!(
1349 !cache.0.contains_key(&key),
1350 "volatile SELECT must not populate result cache, found key {key:?} in {:?}",
1351 cache.0.keys().collect::<Vec<_>>()
1352 );
1353
1354 reset_thread_locals();
1355 }
1356
1357 #[test]
1358 fn blob_cache_backend_populates_blob_path_without_legacy_write() {
1359 reset_thread_locals();
1360 let rt = fresh_runtime();
1361 rt.inner
1362 .db
1363 .store()
1364 .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1365
1366 let result = rt.execute_query("SELECT 1").expect("SELECT 1 executes");
1367 assert_eq!(result.statement_type, "select");
1368
1369 let key = result_cache_key("SELECT 1");
1370 assert!(
1371 rt.inner
1372 .result_blob_cache
1373 .get("runtime.result_cache", &key)
1374 .is_some(),
1375 "blob backend should stamp the Blob Cache path"
1376 );
1377 assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1378 assert!(
1379 !rt.inner.result_cache.read().0.contains_key(&key),
1380 "blob backend should not write the legacy map"
1381 );
1382 }
1383
1384 #[test]
1385 fn blob_cache_backend_keeps_volatile_select_out_of_blob_path() {
1386 reset_thread_locals();
1387 let rt = fresh_runtime();
1388 rt.inner
1389 .db
1390 .store()
1391 .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1392
1393 let _ = rt
1394 .execute_query("SELECT pg_advisory_unlock(1)")
1395 .expect("volatile SELECT executes");
1396 let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1397 assert!(
1398 rt.inner
1399 .result_blob_cache
1400 .get("runtime.result_cache", &key)
1401 .is_none(),
1402 "volatile SELECT must not populate blob result cache"
1403 );
1404 assert!(!rt.inner.result_blob_entries.read().0.contains_key(&key));
1405 }
1406
1407 #[test]
1408 fn shadow_backend_dual_writes_and_reports_no_divergence_on_equal_results() {
1409 reset_thread_locals();
1410 let rt = fresh_runtime();
1411 rt.inner
1412 .db
1413 .store()
1414 .set_config_tree("runtime.result_cache.backend", &crate::json!("shadow"));
1415
1416 let first = rt.execute_query("SELECT 1").expect("first SELECT");
1417 let second = rt.execute_query("SELECT 1").expect("cached SELECT");
1418 assert_eq!(first.result.len(), second.result.len());
1419
1420 let key = result_cache_key("SELECT 1");
1421 assert!(rt.inner.result_cache.read().0.contains_key(&key));
1422 assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1423 assert_eq!(rt.result_cache_shadow_divergences(), 0);
1424 assert_eq!(
1425 crate::runtime::METRIC_CACHE_SHADOW_DIVERGENCE_TOTAL,
1426 "cache_shadow_divergence_total"
1427 );
1428 }
1429
1430 #[test]
1431 fn as_of_on_red_collection_records_floor() {
1432 reset_thread_locals();
1433 let rt = fresh_runtime();
1434
1435 // `red_*` collections always allow AS OF. The frame should
1436 // resolve to a concrete xid and surface it via the Interface.
1437 let frame = StatementExecutionFrame::build(
1438 &rt,
1439 StatementIdentity::Text("SELECT * FROM red_commits AS OF SNAPSHOT 1"),
1440 )
1441 .expect("AS OF SNAPSHOT 1 on red_commits resolves");
1442
1443 let f: &dyn ReadFrame = &frame;
1444 assert_eq!(
1445 f.as_of_floor(),
1446 Some(1),
1447 "AS OF SNAPSHOT 1 records xid=1 as the floor"
1448 );
1449 assert_eq!(f.snapshot().xid, 1);
1450 assert!(
1451 f.snapshot().in_progress.is_empty(),
1452 "historical reads have no in-progress set"
1453 );
1454 }
1455
1456 /// The frame classifies common SQL prefixes into the coarse
1457 /// `Privilege` / `LockIntent` buckets at build time. This test
1458 /// pins the mapping so a regression that silently re-routes
1459 /// (e.g. INSERT classified as Read) surfaces here, not at a
1460 /// downstream privilege gate.
1461 #[test]
1462 fn frame_classifies_privilege_and_lock_intent_from_prefix() {
1463 reset_thread_locals();
1464 let rt = fresh_runtime();
1465
1466 let cases = [
1467 ("SELECT 1", Privilege::Read, LockIntent::Shared),
1468 ("LIST KV settings", Privilege::Read, LockIntent::Shared),
1469 (
1470 "KV GET settings.feature",
1471 Privilege::Read,
1472 LockIntent::Shared,
1473 ),
1474 ("VAULT LIST secrets", Privilege::Read, LockIntent::Shared),
1475 (
1476 "INSERT INTO t (id) VALUES (1)",
1477 Privilege::Write,
1478 LockIntent::Exclusive,
1479 ),
1480 (
1481 "KV PUT settings.feature = 'on'",
1482 Privilege::Write,
1483 LockIntent::Exclusive,
1484 ),
1485 (
1486 "VAULT PUT secrets.api = 'x'",
1487 Privilege::Write,
1488 LockIntent::Exclusive,
1489 ),
1490 (
1491 "UPDATE t SET x = 1 WHERE id = 1",
1492 Privilege::Write,
1493 LockIntent::Exclusive,
1494 ),
1495 (
1496 "DELETE FROM t WHERE id = 1",
1497 Privilege::Write,
1498 LockIntent::Exclusive,
1499 ),
1500 (
1501 "CREATE TABLE foo (id INT)",
1502 Privilege::Write,
1503 LockIntent::Exclusive,
1504 ),
1505 ("BEGIN", Privilege::None, LockIntent::None),
1506 ("COMMIT", Privilege::None, LockIntent::None),
1507 ("SET timezone = 'UTC'", Privilege::None, LockIntent::None),
1508 ];
1509
1510 for (q, want_priv, want_lock) in cases {
1511 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(q))
1512 .unwrap_or_else(|e| panic!("frame builds for {q:?}: {e}"));
1513 let f: &dyn ReadFrame = &frame;
1514 assert_eq!(f.required_privilege(), want_priv, "privilege for {q:?}");
1515 assert_eq!(f.lock_intent(), want_lock, "lock intent for {q:?}");
1516 }
1517 }
1518
1519 /// Deletion-test for `ReadFrame::required_privilege`: a SELECT
1520 /// driven through `execute_query` under an identity whose role
1521 /// doesn't satisfy the frame's coarse `Read` privilege gets
1522 /// denied with the frame's signal.
1523 ///
1524 /// We test the gate by classifying an INSERT (which the frame
1525 /// reports as `Privilege::Write`) under `Role::Read` — the only
1526 /// pair the legacy fallback would also reject, but here the
1527 /// rejection comes through `frame.check_query_privilege` BEFORE
1528 /// the parsed-expression walker runs. Removing
1529 /// `required_privilege` (or the `is_satisfied_by` consult inside
1530 /// `check_query_privilege`) would force the deny path back to the
1531 /// inline `RedDBRuntime::check_query_privilege` walker — but the
1532 /// auth_store gate up there is bypassed when no auth_store is
1533 /// wired (embedded test mode), so this test would FLIP from
1534 /// denied to permitted and break the assertion below.
1535 #[test]
1536 fn insert_under_read_role_denied_via_frame_privilege() {
1537 reset_thread_locals();
1538 set_current_auth_identity("alice".to_string(), Role::Read);
1539
1540 let rt = fresh_runtime();
1541 // Bypass parser by reaching into the frame directly: the
1542 // frame derives privilege from the SQL prefix without
1543 // needing an auth_store wired up. Driving end-to-end via
1544 // `execute_query` would also reject (no table `t`), but for
1545 // a different reason — we want to pin the privilege seam.
1546 let frame = StatementExecutionFrame::build(
1547 &rt,
1548 StatementIdentity::Text("INSERT INTO t (id) VALUES (1)"),
1549 )
1550 .expect("frame builds for INSERT");
1551 let f: &dyn ReadFrame = &frame;
1552 assert_eq!(
1553 f.required_privilege(),
1554 Privilege::Write,
1555 "INSERT classified as Write"
1556 );
1557 let id = f.identity().expect("identity captured");
1558 assert!(
1559 !f.required_privilege().is_satisfied_by(id.1),
1560 "Role::Read does not satisfy Privilege::Write — frame must deny"
1561 );
1562
1563 // End-to-end: the frame's `check_query_privilege` sees the
1564 // (Read role, Write privilege) mismatch and denies before
1565 // dispatch. We drive a synthetic `QueryExpr::Table` because
1566 // the SELECT/INSERT parser would happen to also fail, and we
1567 // want the failure to come from the privilege seam.
1568 use reddb_rql::ast::{QueryExpr, TableQuery};
1569 let expr = QueryExpr::Table(TableQuery::new("t"));
1570 let err = frame
1571 .check_query_privilege(&rt, &expr)
1572 .expect_err("denied via frame's coarse privilege gate");
1573 let msg = format!("{err}");
1574 assert!(
1575 msg.contains("permission denied") && msg.contains("Write"),
1576 "expected frame-level Write deny, got: {msg}"
1577 );
1578
1579 reset_thread_locals();
1580 }
1581
1582 /// End-to-end proof that the frame-owned row-buffer arena (#885) is
1583 /// wired into the SELECT path and produces observable results
1584 /// byte-identical to the per-request-allocation baseline.
1585 ///
1586 /// A table with more rows than the streaming high-water mark
1587 /// (`DEFAULT_HIGH_WATER_MARK`) forces the `execute_runtime_table_query_in`
1588 /// path to assemble many chunks, each leasing/recycling the frame
1589 /// arena's single chunk buffer. Driving it through `execute_query`
1590 /// (which builds a `StatementExecutionFrame` and lends its arena)
1591 /// must return every inserted row, in order — exactly what the
1592 /// allocate-per-chunk path returned. A bug in the arena wiring
1593 /// (dropped rows, bled rows, mis-ordering) would surface here.
1594 #[test]
1595 fn large_select_through_frame_arena_returns_all_rows_in_order() {
1596 reset_thread_locals();
1597 let rt = fresh_runtime();
1598 rt.execute_query("CREATE TABLE big (id INT)")
1599 .expect("create table");
1600
1601 // > DEFAULT_HIGH_WATER_MARK (1024) rows so the streaming channel
1602 // spans multiple chunks and the arena buffer is reused.
1603 const N: usize = 2_500;
1604 for start in (0..N).step_by(250) {
1605 let end = (start + 250).min(N);
1606 let values = (start..end)
1607 .map(|i| format!("({i})"))
1608 .collect::<Vec<_>>()
1609 .join(", ");
1610 rt.execute_query(&format!("INSERT INTO big (id) VALUES {values}"))
1611 .unwrap_or_else(|err| panic!("insert rows {start}..{end}: {err:?}"));
1612 }
1613
1614 let result = rt
1615 .execute_query("SELECT id FROM big ORDER BY id")
1616 .expect("large SELECT executes through the frame arena path");
1617 assert_eq!(result.statement_type, "select");
1618 assert_eq!(
1619 result.result.records.len(),
1620 N,
1621 "every inserted row streams back through the arena-backed channel"
1622 );
1623 for (i, record) in result.result.records.iter().enumerate() {
1624 assert_eq!(
1625 record.get("id"),
1626 Some(&reddb_types::Value::Integer(i as i64)),
1627 "row {i} is byte-identical to the per-request-allocation baseline"
1628 );
1629 }
1630
1631 reset_thread_locals();
1632 }
1633
1634 /// Transport adapters may decode their wire-specific parameter value
1635 /// shapes, but SQL parsing/binding must stay behind the runtime's
1636 /// statement entrypoint. This pins the deeper seam introduced for
1637 /// parameterized query execution. HTTP, JSON-RPC, RedWire, and gRPC hand
1638 /// a `QueryRequest` to the Request module, as does PG wire through the
1639 /// shared prepared registry. All paths install a real
1640 /// `StatementExecutionFrame` before dispatch.
1641 ///
1642 /// The Request transports reach the runtime entrypoint one layer deeper:
1643 /// `QueryRequestExecutor` calls `execute_query_with_params` on their
1644 /// behalf. Their delegation target is the Request seam, so that is what
1645 /// is pinned here — the "no binding in the adapter" half is unchanged
1646 /// for all.
1647 #[test]
1648 fn parameterized_transport_adapters_delegate_binding_to_runtime() {
1649 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1650 let adapters = [
1651 ("src/server/handlers_query.rs", "QueryRequestExecutor"),
1652 ("src/rpc_stdio.rs", "QueryRequestExecutor"),
1653 ("src/wire/redwire/session.rs", "QueryRequestExecutor"),
1654 ("src/wire/postgres/server.rs", "QueryRequestExecutor"),
1655 ("src/grpc.rs", "QueryRequestExecutor"),
1656 ];
1657
1658 for (relative, delegation) in adapters {
1659 let path = manifest_dir.join(relative);
1660 let text = std::fs::read_to_string(&path)
1661 .unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
1662 assert!(
1663 text.contains(delegation),
1664 "{relative} should delegate parameterized query execution through {delegation}"
1665 );
1666 assert!(
1667 !text.contains("user_params::bind"),
1668 "{relative} must not bind SQL params in the transport adapter"
1669 );
1670 }
1671
1672 // The Request module is the only place RedWire's delegation may
1673 // bottom out: it must still reach the runtime entrypoint itself.
1674 let seam = std::fs::read_to_string(manifest_dir.join("src/runtime/query_request.rs"))
1675 .expect("read src/runtime/query_request.rs");
1676 assert!(
1677 seam.contains("execute_query_with_params"),
1678 "the Request module must execute text SQL through the runtime entrypoint"
1679 );
1680 }
1681
1682 /// Deletion-test for `ReadFrame::lock_intent`: a transaction
1683 /// control statement carries `LockIntent::None` and the
1684 /// `acquire_intent_locks` path returns `None` without consulting
1685 /// `intent_lock_modes_for`. Removing the method (or its consult
1686 /// site in `acquire_intent_locks`) would force the lock-mode
1687 /// helper to walk a fabricated parsed expression to reach the
1688 /// same conclusion — but the assertion that no guard is allocated
1689 /// for a `BEGIN` frame would still hold, so we additionally pin
1690 /// the classifier mapping above to make the deletion observable.
1691 #[test]
1692 fn control_statement_skips_intent_locks_via_frame() {
1693 reset_thread_locals();
1694 let rt = fresh_runtime();
1695
1696 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("BEGIN"))
1697 .expect("frame builds for BEGIN");
1698 let f: &dyn ReadFrame = &frame;
1699 assert_eq!(f.lock_intent(), LockIntent::None);
1700
1701 // Drive `acquire_intent_locks` against a fabricated SELECT
1702 // expression that WOULD normally yield `(IS, IS)`; the frame's
1703 // `lock_intent() == None` short-circuit must still suppress
1704 // the guard.
1705 use reddb_rql::ast::{QueryExpr, TableQuery};
1706 let expr = QueryExpr::Table(TableQuery::new("t"));
1707 let guard = frame.acquire_intent_locks(&rt, &expr);
1708 assert!(
1709 guard.is_none(),
1710 "BEGIN frame's lock_intent=None must short-circuit lock acquisition"
1711 );
1712 }
1713
1714 /// Nested sub-execution inside one statement (CTE prelude,
1715 /// materialized-view refresh, inline graph TVF) must read the
1716 /// enclosing statement's snapshot. Autocommit is the case that used
1717 /// to break: `current_snapshot()` mints a fresh high-water mark per
1718 /// call, so an inline graph TVF could materialize its nodes and its
1719 /// edges under two different snapshots within one statement.
1720 #[test]
1721 fn nested_frame_inherits_outer_statement_snapshot() {
1722 reset_thread_locals();
1723 let rt = fresh_runtime();
1724 rt.execute_query("CREATE TABLE nested_snap (id INT)")
1725 .expect("create table");
1726
1727 let outer = StatementExecutionFrame::build(
1728 &rt,
1729 StatementIdentity::Text("SELECT * FROM nested_snap"),
1730 )
1731 .expect("outer frame builds");
1732 let _outer_guards = outer.install(&rt);
1733
1734 // A write between the two builds moves the high-water mark, so a
1735 // re-minting nested frame would land on a different xid.
1736 rt.execute_query("INSERT INTO nested_snap (id) VALUES (1)")
1737 .expect("concurrent insert");
1738
1739 let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("nested_snap"));
1740 let nested = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
1741 .expect("nested frame builds");
1742
1743 assert_eq!(
1744 ReadFrame::snapshot(&nested).xid,
1745 ReadFrame::snapshot(&outer).xid,
1746 "nested execution must inherit the outer statement's snapshot"
1747 );
1748 }
1749
1750 /// The other side of the same rule, and the one that matters more:
1751 /// a nested statement built from *text* is a statement in its own
1752 /// right, not part of the enclosing one, so it must mint a fresh
1753 /// snapshot and observe everything committed before it.
1754 ///
1755 /// `apply_batched` (`impl_migrations.rs`) is the load-bearing caller:
1756 /// it drives `APPLY MIGRATION … BATCH N ROWS` by re-running the body
1757 /// with `LIMIT N` through `execute_query` until fewer than `N` rows
1758 /// are affected. Inheriting the outer `APPLY MIGRATION` statement's
1759 /// snapshot froze that loop's view, so every iteration re-selected
1760 /// the same rows and the migration never converged — 25 rows at
1761 /// `BATCH 7` hung instead of finishing in four iterations.
1762 #[test]
1763 fn nested_text_statement_sees_commits_made_since_the_outer_frame() {
1764 reset_thread_locals();
1765 let rt = fresh_runtime();
1766 rt.execute_query("CREATE TABLE nested_text_snap (id INT)")
1767 .expect("create table");
1768
1769 let outer = StatementExecutionFrame::build(
1770 &rt,
1771 StatementIdentity::Text("SELECT * FROM nested_text_snap"),
1772 )
1773 .expect("outer frame builds");
1774 let _outer_guards = outer.install(&rt);
1775
1776 rt.execute_query("INSERT INTO nested_text_snap (id) VALUES (1)")
1777 .expect("concurrent insert");
1778
1779 let nested = StatementExecutionFrame::build(
1780 &rt,
1781 StatementIdentity::Text("SELECT * FROM nested_text_snap"),
1782 )
1783 .expect("nested frame builds");
1784
1785 assert_ne!(
1786 ReadFrame::snapshot(&nested).xid,
1787 ReadFrame::snapshot(&outer).xid,
1788 "a nested text statement must mint a fresh snapshot, not inherit \
1789 the enclosing statement's frozen view"
1790 );
1791 }
1792
1793 /// A frame with no statement text must not silently answer an
1794 /// `AS OF` read with live data. `TableQuery::as_of` is consumed
1795 /// nowhere in the runtime or scan path, so the only correct
1796 /// behaviours are "resolve it" (text path) or "refuse".
1797 #[test]
1798 fn expr_frame_rejects_as_of_instead_of_reading_live() {
1799 reset_thread_locals();
1800 let rt = fresh_runtime();
1801
1802 let mut table = reddb_rql::ast::TableQuery::new("red_commits");
1803 table.as_of = Some(reddb_rql::ast::AsOfClause::Snapshot(1));
1804 let expr = QueryExpr::Table(table);
1805
1806 let err = match StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr)) {
1807 Err(err) => err,
1808 Ok(_) => panic!("AS OF without statement text must be refused, not read live"),
1809 };
1810 let msg = format!("{err}");
1811 assert!(
1812 msg.contains("AS OF cannot be resolved from a pre-parsed expression"),
1813 "expected a loud AS OF refusal, got: {msg}"
1814 );
1815 }
1816
1817 /// The expression path has no stable text key, so it must opt out of
1818 /// the result cache entirely. A kind label (`"table"`, `"insert"`)
1819 /// would key every statement of the same kind to one cache slot.
1820 #[test]
1821 fn expr_frame_opts_out_of_the_result_cache() {
1822 reset_thread_locals();
1823 let rt = fresh_runtime();
1824
1825 let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("t"));
1826 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
1827 .expect("expr frame builds");
1828
1829 assert!(frame.cache_key().is_empty(), "expr frame has no cache key");
1830 assert!(
1831 !frame.can_read_result_cache(),
1832 "expr frame must never serve from the result cache"
1833 );
1834 }
1835}