Skip to main content

reddb_server/runtime/
impl_core.rs

1use super::*;
2use crate::auth::column_policy_gate::ColumnAccessRequest;
3use crate::auth::UserId;
4use crate::catalog::CollectionModel;
5use crate::replication::cdc::ChangeRecord;
6use reddb_rql::ast::TableSource;
7// Authorization surface moved to `super::authz` (issue #1622). Re-export the
8// free IAM/policy-column helpers so this module's remaining dispatch code
9// (and the `impl_core::decision_to_strings` path used by
10// `server::handlers_iam_policy`) keeps calling them unqualified.
11pub(crate) use super::authz::policy_columns::*;
12
13// Graph inline-TVF/analytics moved to `super::graph_tvf` (issue #1625) and the
14// RLS injection family to `super::rls_injection`. Re-export the free fns still
15// referenced from this module (and via `crate::runtime::impl_core::…` paths by
16// sibling files) so those call sites need no edits.
17pub(crate) use super::graph_tvf::{abstract_degree_centrality, is_graph_tvf_name};
18pub(crate) use super::rls_injection::{
19    apply_foreign_table_filters, inject_rls_filters, inject_rls_into_join, rls_is_enabled,
20    rls_policy_filter, rls_policy_filter_for_kind,
21};
22// VCS command parse/execute moved to `super::vcs_command` (issue #1626). The
23// execute methods are inherent `pub(crate)` methods resolved via `self`; the
24// free parse helpers still called from the central dispatch (and by
25// `collections_referenced` here) are re-exported so those call sites need no
26// edits.
27pub(crate) use super::vcs_command::{
28    parse_runtime_vcs_command, strip_explain_prefix, walk_collections,
29};
30
31// Ranking / metrics / SLO / analytics-source DDL moved to
32// `super::impl_ranking` (issue #1627). Execute methods are inherent
33// `pub(crate)` methods resolved via `self`; no call-site edits needed.
34
35// Config / secret / KV resolution moved to `super::impl_config_secret` and the
36// query-audit + control-event families to `super::impl_audit_control`
37// (issue #1628). Methods are inherent `pub(crate)` methods resolved via `self`;
38// the one free fn still called via a `crate::runtime::impl_core::…` path
39// (`control_event_outcome_for_error`, from `impl_backup`) is re-exported so that
40// call site needs no edit. The dispatch here also still calls the three
41// audit-planning free fns, so they are pulled back into scope unqualified.
42pub(crate) use super::impl_audit_control::{
43    control_event_outcome_for_error, query_audit_plan, query_control_event_specs,
44};
45// Config/secret free helpers still called unqualified from the dispatch here.
46pub(crate) use super::impl_config_secret::{
47    insert_config_json_path, secret_sql_value_to_string, seed_storage_deploy_config,
48    show_config_json_result,
49};
50pub(super) use super::impl_core_outer_scope::{
51    first_column_values, query_references_outer_scope, relation_scopes_for_query,
52};
53pub(crate) use super::impl_core_result_cache_scopes::collect_table_refs;
54pub(super) use super::impl_core_result_cache_scopes::query_expr_result_cache_scopes;
55
56// Bootstrap / constructor / handle accessors moved to `super::impl_lifecycle`,
57// telemetry / gates / limits / shutdown to `super::impl_telemetry_accessors`,
58// and catalog / stats / maintenance to `super::impl_catalog_accessors`
59// (issue #1629). Methods are inherent `pub(crate)`/`pub` methods resolved via
60// `self`; the two moved free fns still called unqualified from the dispatch
61// here (`view_records_to_entities`) and the runtime-index rehydration paths
62// (`table_row_index_fields`) are re-exported so those call sites need no edit.
63pub(crate) use super::impl_lifecycle::{table_row_index_fields, view_records_to_entities};
64
65pub use super::execution_context::{
66    capture_current_snapshot, clear_current_auth_identity, clear_current_connection_id,
67    clear_current_snapshot, clear_current_tenant, current_auth_identity_for_audit,
68    current_connection_id, current_tenant, entity_visible_under_current_snapshot,
69    entity_visible_with_context, set_current_auth_identity, set_current_connection_id,
70    set_current_snapshot, set_current_tenant, snapshot_bundle, with_snapshot_bundle,
71    SnapshotBundle, SnapshotContext,
72};
73pub(crate) use super::execution_context::{
74    config_read_permitted, current_auth_identity, current_config_value, current_kv_value,
75    current_role_projected, current_scope_override, current_secret_value,
76    current_snapshot_requires_index_fallback, current_user_projected, has_scope_override_active,
77    kv_read_permitted, parse_set_local_tenant, update_current_config_value,
78    update_current_kv_value, update_current_secret_value, xids_visible_under_current_snapshot,
79    ConfigSnapshotGuard, CurrentSnapshotGuard, KvStoreGuard, ScopeOverrideGuard, SecretStoreGuard,
80    TxLocalTenantGuard,
81};
82
83/// Cheap prefix check for a leading `WITH` keyword. Used to gate the
84/// CTE-aware parse in `execute_query` without paying for a full
85/// lexer pass on every statement. Treats `WITHIN` as not-a-CTE so
86/// `WITHIN TENANT '...' SELECT ...` doesn't mis-route.
87pub(super) fn has_with_prefix(sql: &str) -> bool {
88    let trimmed = sql.trim_start();
89    let head_end = trimmed
90        .find(|c: char| c.is_whitespace() || c == '(')
91        .unwrap_or(trimmed.len());
92    trimmed[..head_end].eq_ignore_ascii_case("WITH")
93}
94
95/// Same as `peek_top_level_as_of` but also returns the table name
96/// targeted by the AS OF clause (when the FROM clause names a
97/// concrete table). `None` for the table slot means scalar SELECT
98/// or a subquery source — callers treat those as "no enforcement".
99pub(super) fn peek_top_level_as_of_with_table(
100    sql: &str,
101) -> Option<(crate::application::vcs::AsOfSpec, Option<String>)> {
102    if !sql
103        .as_bytes()
104        .windows(5)
105        .any(|w| w.eq_ignore_ascii_case(b"as of"))
106    {
107        return None;
108    }
109    let parsed = reddb_rql::parser::parse(sql).ok()?;
110    let reddb_rql::ast::QueryExpr::Table(table) = parsed.query else {
111        return None;
112    };
113    let clause = table.as_of?;
114    let table_name = if table.table.is_empty() || table.table == "any" {
115        None
116    } else {
117        Some(table.table.clone())
118    };
119    let spec = match clause {
120        reddb_rql::ast::AsOfClause::Commit(h) => crate::application::vcs::AsOfSpec::Commit(h),
121        reddb_rql::ast::AsOfClause::Branch(b) => crate::application::vcs::AsOfSpec::Branch(b),
122        reddb_rql::ast::AsOfClause::Tag(t) => crate::application::vcs::AsOfSpec::Tag(t),
123        reddb_rql::ast::AsOfClause::TimestampMs(ts) => {
124            crate::application::vcs::AsOfSpec::TimestampMs(ts)
125        }
126        reddb_rql::ast::AsOfClause::Snapshot(x) => crate::application::vcs::AsOfSpec::Snapshot(x),
127    };
128    Some((spec, table_name))
129}
130
131pub(super) fn query_has_volatile_builtin(sql: &str) -> bool {
132    // Lowercase the bytes up to the first null/newline into a small
133    // stack buffer for cheap contains() checks. Most SQL fits in the
134    // buffer; longer queries fall back to owned lowercase.
135    const VOLATILE_TOKENS: &[&str] = &[
136        "pg_advisory_lock",
137        "pg_try_advisory_lock",
138        "pg_advisory_unlock",
139        "random()",
140        // `$config.<path>` / `$secret.<path>` resolve mutable runtime config /
141        // vault state at execution time (#1370). A cached result would serve a
142        // stale value after a later `SET CONFIG` / `SET SECRET`, so treat any
143        // query referencing them as volatile (never result-cache it).
144        "$config",
145        "$secret",
146        "$red.secret",
147        "$kv",
148        // NOW() / CURRENT_TIMESTAMP / CURRENT_DATE intentionally
149        // omitted for now — they ARE volatile but today's tests rely
150        // on caching them. Revisit once a tighter volatility story
151        // lands.
152    ];
153    let lowered = sql.to_ascii_lowercase();
154    VOLATILE_TOKENS.iter().any(|t| lowered.contains(t))
155}
156
157pub(super) fn query_is_ask_statement(sql: &str) -> bool {
158    let trimmed = sql.trim_start();
159    let head_end = trimmed
160        .find(|c: char| c.is_whitespace() || c == '(' || c == ';')
161        .unwrap_or(trimmed.len());
162    trimmed[..head_end].eq_ignore_ascii_case("ASK")
163}
164
165/// Pick the `(global_mode, collection_mode)` pair for an expression,
166/// or `None` for variants that opt out of intent-locking entirely
167/// (admin statements like `SHOW CONFIG`, transaction control, tenant
168/// toggles).
169///
170/// Phase-1 contract:
171/// - Reads  — `(IX-compatible) (Global, IS) → (Collection, IS)`
172/// - Writes — `(IX-compatible) (Global, IX) → (Collection, IX)`
173/// - DDL    — `(strong)        (Global, IX) → (Collection, X)`
174pub(super) fn intent_lock_modes_for(
175    expr: &QueryExpr,
176) -> Option<(
177    crate::runtime::lock_manager::LockMode,
178    crate::runtime::lock_manager::LockMode,
179)> {
180    use crate::runtime::lock_manager::LockMode::{Exclusive, IntentExclusive, IntentShared};
181
182    match expr {
183        // Reads — IS / IS.
184        QueryExpr::Table(_)
185        | QueryExpr::Join(_)
186        | QueryExpr::Vector(_)
187        | QueryExpr::Hybrid(_)
188        | QueryExpr::Graph(_)
189        | QueryExpr::Path(_)
190        | QueryExpr::Ask(_)
191        | QueryExpr::SearchCommand(_)
192        | QueryExpr::GraphCommand(_)
193        | QueryExpr::RankOf(_)
194        | QueryExpr::ApproxRankOf(_)
195        | QueryExpr::RankRange(_)
196        | QueryExpr::QueueSelect(_) => Some((IntentShared, IntentShared)),
197
198        // Writes — IX / IX. Non-tabular mutations (vector insert,
199        // graph node insert, queue push, timeseries point insert)
200        // don't carry their own dispatch arm here; they ride through
201        // the Insert variant or a command variant covered by the
202        // read-side arm above. P1.T4 expands only the TableQuery-ish
203        // writes; non-tabular kinds inherit when their DML variants
204        // land in later phases.
205        QueryExpr::Insert(_)
206        | QueryExpr::Update(_)
207        | QueryExpr::Delete(_)
208        | QueryExpr::QueueCommand(QueueCommand::Move { .. }) => {
209            Some((IntentExclusive, IntentExclusive))
210        }
211        QueryExpr::QueueCommand(_) => Some((IntentShared, IntentShared)),
212
213        // DDL — IX / X. A DDL against collection `c` blocks all
214        // other writers + readers on `c` but leaves other collections
215        // running (because Global stays IX, not X).
216        QueryExpr::CreateTable(_)
217        | QueryExpr::CreateCollection(_)
218        | QueryExpr::CreateVector(_)
219        | QueryExpr::DropTable(_)
220        | QueryExpr::DropGraph(_)
221        | QueryExpr::DropVector(_)
222        | QueryExpr::DropDocument(_)
223        | QueryExpr::DropKv(_)
224        | QueryExpr::DropCollection(_)
225        | QueryExpr::Truncate(_)
226        | QueryExpr::AlterTable(_)
227        | QueryExpr::CreateIndex(_)
228        | QueryExpr::DropIndex(_)
229        | QueryExpr::CreateTimeSeries(_)
230        | QueryExpr::CreateMetric(_)
231        | QueryExpr::AlterMetric(_)
232        | QueryExpr::CreateSlo(_)
233        | QueryExpr::DropTimeSeries(_)
234        | QueryExpr::CreateQueue(_)
235        | QueryExpr::AlterQueue(_)
236        | QueryExpr::DropQueue(_)
237        | QueryExpr::CreateTree(_)
238        | QueryExpr::DropTree(_)
239        | QueryExpr::CreatePolicy(_)
240        | QueryExpr::DropPolicy(_)
241        | QueryExpr::CreateView(_)
242        | QueryExpr::DropView(_)
243        | QueryExpr::RefreshMaterializedView(_)
244        | QueryExpr::CreateSchema(_)
245        | QueryExpr::DropSchema(_)
246        | QueryExpr::CreateSequence(_)
247        | QueryExpr::DropSequence(_)
248        | QueryExpr::CreateServer(_)
249        | QueryExpr::DropServer(_)
250        | QueryExpr::CreateForeignTable(_)
251        | QueryExpr::DropForeignTable(_) => Some((IntentExclusive, Exclusive)),
252
253        // Admin / control — skip intent locks. `SET TENANT`,
254        // `BEGIN / COMMIT / ROLLBACK`, `SET CONFIG`, `SHOW CONFIG`,
255        // `VACUUM`, etc. don't touch collection data the same way
256        // and the existing transaction layer already serialises the
257        // pieces that matter.
258        _ => None,
259    }
260}
261
262/// Best-effort collection inventory for an expression. Used to pick
263/// `Collection(...)` resources for the intent-lock guard. Overshoots
264/// are fine (take an extra IS, benign); undershoots leak writes past
265/// DDL X locks, so err on the side of listing more names.
266pub(super) fn collections_referenced(expr: &QueryExpr) -> Vec<String> {
267    let mut out = Vec::new();
268    walk_collections(expr, &mut out);
269    out.sort();
270    out.dedup();
271    out
272}
273
274impl RedDBRuntime {
275    /// Execute a query under a typed scope override without embedding
276    /// the tenant / user / role values into the SQL string. Use this
277    /// from transport middleware (HTTP / gRPC / worker loops) where the
278    /// scope is resolved from auth claims and the SQL is a parameterised
279    /// template — avoids the string-concat injection risk of building
280    /// `WITHIN TENANT '<id>' …` manually, and is drop-in compatible with
281    /// prepared statements that didn't know about tenancy.
282    ///
283    /// Precedence matches the `WITHIN` clause: the passed `scope`
284    /// overrides `SET LOCAL TENANT`, which overrides `SET TENANT`.
285    /// The override is pushed on the thread-local scope stack for the
286    /// duration of the call and popped on return — pool-shared
287    /// connections cannot leak it across requests.
288    pub fn execute_query_with_scope(
289        &self,
290        query: &str,
291        scope: crate::runtime::within_clause::ScopeOverride,
292    ) -> RedDBResult<RuntimeQueryResult> {
293        if scope.is_empty() {
294            return self.execute_query(query);
295        }
296        let _scope_guard = ScopeOverrideGuard::install(scope);
297        self.execute_query(query)
298    }
299
300    /// Issue #205 — single lifecycle exit for slow-query logging.
301    ///
302    /// `execute_query_inner` does the real work; this wrapper times it
303    /// and, if elapsed exceeds the configured threshold, hands the
304    /// triple `(QueryKind, elapsed_ms, sql_redacted, scope)` to the
305    /// SlowQueryLogger. The threshold + sample_pct were captured at
306    /// SlowQueryLogger construction (runtime startup), so the per-call
307    /// cost on below-threshold paths is one relaxed atomic load.
308    pub fn execute_query(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
309        let started = std::time::Instant::now();
310        self.inner.node_load_telemetry.query_start();
311        let result = self.execute_query_inner(query);
312        self.finish_query_lifecycle(query, started, result)
313    }
314
315    /// Execute a SQL statement with already-decoded positional bind
316    /// parameters. Transports should call this instead of parsing +
317    /// binding on their side and then reaching for `execute_query_expr`:
318    /// this entry keeps parameterized statements inside the same
319    /// statement lifecycle as textual SQL (snapshot guard, config/secret
320    /// guards, coarse auth, intent locks, slow-query logging, integrity
321    /// tombstone filtering, and causal bookmarks).
322    pub fn execute_query_with_params(
323        &self,
324        query: &str,
325        params: &[Value],
326    ) -> RedDBResult<RuntimeQueryResult> {
327        if params.is_empty() {
328            return self.execute_query(query);
329        }
330        let started = std::time::Instant::now();
331        self.inner.node_load_telemetry.query_start();
332        let result = self.execute_query_with_params_inner(query, params);
333        self.finish_query_lifecycle(query, started, result)
334    }
335
336    /// Execute an already-bound prepared statement together with the SQL text
337    /// it was prepared from.
338    ///
339    /// This is the prepared-statement entry point for transports. The text is
340    /// what the statement frame is built from — snapshot (including `AS OF`),
341    /// coarse privilege class, intent locks, result-cache key, and slow-query
342    /// logging all describe the real statement — so a prepared execution gets
343    /// the same statement semantics as the identical SQL text (#2183). A
344    /// transport that holds a shape in a prepared registry must keep the
345    /// original text alongside it and pass it here.
346    pub fn execute_prepared_query(
347        &self,
348        query: &str,
349        expr: QueryExpr,
350    ) -> RedDBResult<RuntimeQueryResult> {
351        let started = std::time::Instant::now();
352        self.inner.node_load_telemetry.query_start();
353        let result = self.execute_bound_query_expr_in_frame(query, expr);
354        self.finish_query_lifecycle(query, started, result)
355    }
356
357    fn finish_query_lifecycle(
358        &self,
359        query: &str,
360        started: std::time::Instant,
361        mut result: RedDBResult<RuntimeQueryResult>,
362    ) -> RedDBResult<RuntimeQueryResult> {
363        // Issue #765 / S6 — filter integrity-tombstoned rows out of SELECT
364        // results before they reach any consumer. Fast no-op (one relaxed
365        // atomic load) unless an input-stream digest mismatch has tombstoned
366        // a RID range on this store.
367        if let Ok(ref mut query_result) = result {
368            if query_result.statement_type == "select" {
369                self.filter_integrity_tombstoned(&mut query_result.result);
370            }
371        }
372        let elapsed_ms = started.elapsed().as_millis() as u64;
373
374        // Build EffectiveScope from the same thread-locals frame-build
375        // consults — keeps the slow-log row consistent with the audit /
376        // RLS view of "this statement". `ai_scope()` is the canonical
377        // builder.
378        let scope = self.ai_scope();
379        let kind = match result
380            .as_ref()
381            .map(|r| r.statement_type)
382            .unwrap_or("select")
383        {
384            "select" => crate::telemetry::slow_query_logger::QueryKind::Select,
385            "insert" => crate::telemetry::slow_query_logger::QueryKind::Insert,
386            "update" => crate::telemetry::slow_query_logger::QueryKind::Update,
387            "delete" => crate::telemetry::slow_query_logger::QueryKind::Delete,
388            _ => crate::telemetry::slow_query_logger::QueryKind::Internal,
389        };
390        let lowered = query.to_ascii_lowercase();
391        let logged_query = if lowered.contains("secret") || lowered.contains("vault") {
392            "secret statement (SQL redacted)".to_string()
393        } else {
394            query.to_string()
395        };
396        self.inner
397            .slow_query_logger
398            .record(kind, elapsed_ms, logged_query, &scope);
399
400        // Issue #1241 — record latency into the bounded per-`kind`
401        // histogram substrate (always, not only above the slow-query
402        // threshold). `started.elapsed()` is re-read here for sub-ms
403        // resolution; the cost is one `Instant::now` plus a handful of
404        // relaxed atomic adds (see `query_latency_telemetry` docs).
405        self.inner
406            .query_latency_telemetry
407            .observe(kind, started.elapsed().as_secs_f64());
408
409        // Issue #1245 — decrement the active-query gauge. One relaxed
410        // atomic sub; the matching increment happened at execute_query /
411        // execute_query_with_params entry.
412        self.inner.node_load_telemetry.query_finish();
413
414        if let Ok(ref mut query_result) = result {
415            if matches!(query_result.statement_type, "insert" | "update" | "delete") {
416                let bookmark = crate::replication::CausalBookmark::new(
417                    self.current_replication_term(),
418                    self.cdc_current_lsn(),
419                );
420                query_result.bookmark = Some(bookmark.encode());
421            }
422        }
423
424        result
425    }
426
427    fn execute_query_with_params_inner(
428        &self,
429        query: &str,
430        params: &[Value],
431    ) -> RedDBResult<RuntimeQueryResult> {
432        let parsed = self.parameterized_query_ast(query)?;
433        let bound = crate::storage::query::user_params::bind(&parsed, params).map_err(|err| {
434            RedDBError::Validation {
435                message: err.to_string(),
436                validation: crate::json!({
437                    "code": "INVALID_PARAMS",
438                    "surface": "query.params",
439                }),
440            }
441        })?;
442        self.execute_bound_query_expr_in_frame(query, bound)
443    }
444
445    /// Cache only the unbound parser output. Binding, views, authorization,
446    /// configuration and snapshots are resolved anew for every execution.
447    fn parameterized_query_ast(&self, query: &str) -> RedDBResult<QueryExpr> {
448        use crate::storage::query::planner::{CachedPlan, QueryPlan};
449
450        // Keep exact parameterized statements separate from normalized plans.
451        let key = format!("\0parameters\0{query}");
452        if let Some(parsed) = self
453            .inner
454            .query_cache
455            .read()
456            .peek(&key)
457            .filter(|entry| entry.matches_exact_query(query))
458            .map(|entry| entry.plan.original.clone())
459        {
460            return Ok(parsed);
461        }
462        let parsed = parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
463        let plan = QueryPlan::new(parsed.clone(), parsed.clone(), Default::default());
464        self.inner
465            .query_cache
466            .write()
467            .insert(key, CachedPlan::new(plan).with_exact_query(query));
468        Ok(parsed)
469    }
470
471    fn execute_bound_query_expr_in_frame(
472        &self,
473        query: &str,
474        expr: QueryExpr,
475    ) -> RedDBResult<RuntimeQueryResult> {
476        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
477        let execution_query = rewritten_query.as_deref().unwrap_or(query);
478        let frame = super::statement_frame::StatementExecutionFrame::build(
479            self,
480            super::statement_frame::StatementIdentity::Text(execution_query),
481        )?;
482        // Box the guards so the ~640-byte StatementFrameGuards struct lives on
483        // the heap rather than the call stack — important for recursive paths
484        // (view refresh, nested queries) where the stack can be as small as 2 MB.
485        let _frame_guards = Box::new(frame.install(self));
486        let _log_span = crate::telemetry::span::query_span(query).entered();
487
488        let expr = self.rewrite_view_refs(expr);
489        let mode = detect_mode(execution_query);
490        let control_event_specs = query_control_event_specs(&expr);
491        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
492            Ok(guard) => guard,
493            Err(err) => {
494                let outcome = control_event_outcome_for_error(&err);
495                for spec in &control_event_specs {
496                    self.emit_control_event(
497                        spec.kind,
498                        outcome,
499                        spec.action,
500                        spec.resource.clone(),
501                        Some(err.to_string()),
502                        spec.fields.clone(),
503                    )?;
504                }
505                return Err(err);
506            }
507        };
508
509        self.dispatch_expr(expr, query, mode)
510    }
511
512    pub fn causal_session(&self) -> crate::runtime::CausalSession {
513        crate::runtime::CausalSession {
514            runtime: self.clone(),
515            bookmark: None,
516            wait_timeout: std::time::Duration::from_secs(5),
517        }
518    }
519
520    pub fn wait_for_bookmark(
521        &self,
522        bookmark: &crate::replication::CausalBookmark,
523        timeout: std::time::Duration,
524    ) -> RedDBResult<()> {
525        let deadline = std::time::Instant::now() + timeout;
526        loop {
527            let applied_lsn = self.local_contiguous_applied_lsn();
528            if applied_lsn >= bookmark.commit_lsn() {
529                return Ok(());
530            }
531            let now = std::time::Instant::now();
532            if now >= deadline {
533                return Err(RedDBError::InvalidOperation(format!(
534                    "timed out waiting for causal bookmark lsn {}; applied={}",
535                    bookmark.commit_lsn(),
536                    applied_lsn
537                )));
538            }
539            let remaining = deadline.saturating_duration_since(now);
540            std::thread::sleep(remaining.min(std::time::Duration::from_millis(5)));
541        }
542    }
543
544    fn local_contiguous_applied_lsn(&self) -> u64 {
545        match self.inner.db.options().replication.role {
546            crate::replication::ReplicationRole::Replica { .. } => {
547                self.config_u64("red.replication.last_applied_lsn", 0)
548            }
549            _ => self.cdc_current_lsn(),
550        }
551    }
552
553    #[inline(never)]
554    pub(super) fn execute_query_inner(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
555        // ── ULTRA-TURBO: autocommit `SELECT * FROM t WHERE _entity_id = N` ──
556        //
557        // Moved above every boot-cost the normal path pays (WITHIN
558        // strip, SET LOCAL parse, transaction-state reads, snapshot
559        // guard, and tracing span) because the bench's
560        // `select_point` scenario was observed at 28× vs PostgreSQL —
561        // the dominant cost wasn't the entity fetch but the ceremony
562        // before it. Only fires when there's no ambient transaction
563        // context or WITHIN override, so the snapshot install we skip
564        // truly is a no-op for this query.
565        if !has_scope_override_active()
566            && !query.trim_start().starts_with("WITHIN")
567            && !query.trim_start().starts_with("within")
568            && !self.inner.query_audit.has_rules()
569            && !self
570                .inner
571                .transaction_state
572                .in_transaction(current_connection_id())
573        {
574            if let Some(result) = self.try_fast_entity_lookup(query) {
575                return result;
576            }
577        }
578
579        // `WITHIN TENANT '<id>' [USER '<u>'] [AS ROLE '<r>'] <stmt>` —
580        // strip the prefix, push a stack-scoped override, recurse on
581        // the inner statement, pop on return. Stack lives in a
582        // thread-local but is balanced by the RAII guard, so a
583        // pool-shared connection cannot leak the override across
584        // requests and an early `?` return still pops cleanly.
585        match crate::runtime::within_clause::try_strip_within_prefix(query) {
586            Ok(Some((scope, inner))) => {
587                let _scope_guard = ScopeOverrideGuard::install(scope);
588                // Re-enter the inner path, NOT `execute_query`, so the
589                // slow-query lifecycle hook records exactly one row per
590                // top-level statement (the WITHIN-stripped form would
591                // double-record).
592                return self.execute_query_inner(inner);
593            }
594            Ok(None) => {}
595            Err(msg) => return Err(RedDBError::Query(msg)),
596        }
597
598        // `EXPLAIN ANALYZE <dml>` — paid truth tier. Executes the
599        // mutating statement inside a transaction that is always
600        // rolled back, then reports the actual affected row count.
601        if let Some(inner) = strip_explain_analyze_prefix(query) {
602            return self.explain_analyze_as_rows(query, inner);
603        }
604
605        // `EXPLAIN <stmt>` — introspection. Runs the planner on the
606        // inner statement (WITHOUT executing it) and returns the
607        // CanonicalLogicalNode tree as rows so the caller can see the
608        // operator shape and estimated cost. `EXPLAIN ALTER FOR ...`
609        // is a distinct schema-diff command and continues down the
610        // regular SQL path.
611        if let Some(inner) = strip_explain_prefix(query) {
612            return self.explain_as_rows(query, inner);
613        }
614
615        // `SET LOCAL TENANT '<id>'` — write the per-transaction tenant
616        // override and return. Outside a transaction the statement is
617        // an error (matches PG semantics: SET LOCAL only takes effect
618        // within an active transaction).
619        if let Some(value) = parse_set_local_tenant(query)? {
620            let conn_id = current_connection_id();
621            if !self.inner.transaction_state.in_transaction(conn_id) {
622                return Err(RedDBError::Query(
623                    "SET LOCAL TENANT requires an active transaction".to_string(),
624                ));
625            }
626            self.inner
627                .transaction_state
628                .set_local_tenant(conn_id, value.clone());
629            return Ok(RuntimeQueryResult::ok_message(
630                query.to_string(),
631                &match &value {
632                    Some(id) => format!("local tenant set: {id}"),
633                    None => "local tenant cleared".to_string(),
634                },
635                "set_local_tenant",
636            ));
637        }
638
639        if super::red_schema::is_system_schema_write(query) {
640            return Err(RedDBError::Query(
641                super::red_schema::READ_ONLY_ERROR.to_string(),
642            ));
643        }
644
645        if let Some(command) = parse_runtime_vcs_command(query) {
646            let command = command?;
647            self.check_vcs_command_privilege(&command)?;
648            return self.execute_vcs_command(query, detect_mode(query), command);
649        }
650
651        if let Some(create_source) = super::analytics_source_catalog::parse_create_statement(query)?
652        {
653            return self.execute_create_analytics_source(query, create_source);
654        }
655
656        // Issue #790 — `READ METRIC <path>` is intentionally rejected at
657        // v0. The descriptor itself is readable through
658        // `red.analytics.metrics`; the *output* read returns a
659        // structured error so callers can tell "execution engine not yet
660        // built" apart from "metric does not exist".
661        if let Some(path) = super::metric_descriptor_catalog::parse_read_metric_statement(query) {
662            return Err(super::metric_descriptor_catalog::read_output_unsupported(
663                &path,
664            ));
665        }
666
667        // Issue #918 / ADR 0035 — leaderboard rank capability catalog
668        // declarations are still recognised before the general parser.
669        // Rank reads themselves are parser AST nodes, including Redis-flavor
670        // Z* sugar that desugars to the same canonical rank shapes.
671        if let Some(parsed) = super::ranking_descriptor_catalog::parse_create_ranking(query) {
672            return self.execute_create_ranking(query, parsed?);
673        }
674        if super::ranking_descriptor_catalog::parse_show_rankings(query) {
675            return self.execute_show_rankings(query);
676        }
677
678        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
679        let execution_query = rewritten_query.as_deref().unwrap_or(query);
680
681        let frame = super::statement_frame::StatementExecutionFrame::build(
682            self,
683            super::statement_frame::StatementIdentity::Text(execution_query),
684        )?;
685        let _frame_guards = Box::new(frame.install(self));
686
687        // Phase 6 logging: enter a span stamped with conn_id / tenant
688        // / query_len. Every downstream tracing::info!/warn!/error!
689        // inherits these fields — no need to thread them manually
690        // through storage/scan layers. Entered AFTER the WITHIN /
691        // SET LOCAL TENANT resolution above so the span reflects the
692        // effective scope for this statement.
693        let _log_span = crate::telemetry::span::query_span(query).entered();
694
695        // ── CTE prelude (#41) — `WITH x AS (...) SELECT ... FROM x` ──
696        if let Some(rewritten) = frame.prepare_cte(execution_query)? {
697            return self.execute_query_expr(rewritten);
698        }
699
700        // ── TURBO: bypass SQL parse for SELECT * FROM x WHERE _entity_id = N ──
701        if !self.inner.query_audit.has_rules() {
702            if let Some(result) = self.try_fast_entity_lookup(execution_query) {
703                return result;
704            }
705        }
706
707        // ── Result cache: return cached result if still fresh (30s TTL) ──
708        if !self.inner.query_audit.has_rules() {
709            if let Some(result) = frame.read_result_cache(self) {
710                return Ok(result);
711            }
712        }
713
714        let prepared = frame.prepare_statement(self, execution_query)?;
715        let mode = prepared.mode;
716        let expr = prepared.expr;
717
718        let statement = query_expr_name(&expr);
719        let result_cache_scopes = query_expr_result_cache_scopes(&expr);
720        let control_event_specs = query_control_event_specs(&expr);
721        let query_audit_plan = query_audit_plan(&expr);
722
723        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
724            Ok(guard) => guard,
725            Err(err) => {
726                let outcome = control_event_outcome_for_error(&err);
727                for spec in &control_event_specs {
728                    self.emit_control_event(
729                        spec.kind,
730                        outcome,
731                        spec.action,
732                        spec.resource.clone(),
733                        Some(err.to_string()),
734                        spec.fields.clone(),
735                    )?;
736                }
737                return Err(err);
738            }
739        };
740        let frame_iface: &dyn super::statement_frame::ReadFrame = &frame;
741        let query_audit_started = std::time::Instant::now();
742
743        let query_result = match expr {
744            QueryExpr::Function(ref command) => self.execute_function(query, command),
745            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
746                self.execute_materialized_graph(query, mode, statement, &expr)
747            }
748            QueryExpr::Table(table) => {
749                let table = self.resolve_table_expr_subqueries(
750                    table,
751                    &frame as &dyn super::statement_frame::ReadFrame,
752                )?;
753                // Table-valued functions (e.g. components(g)) dispatch to a
754                // read-only executor before any catalog/virtual-table routing
755                // (issue #795).
756                if let Some(TableSource::Function {
757                    name,
758                    args,
759                    named_args,
760                }) = table.source.clone()
761                {
762                    // The graph-collection form is cacheable (issue #802): the
763                    // result-cache read at the top of this function keys on the
764                    // query string, and `result_cache_scopes` carries the graph
765                    // collection (see `collect_table_source_scopes`) so a write
766                    // to it invalidates the entry. Deterministic algorithm
767                    // output is worth caching at any row count, so the write
768                    // bypasses the generic ≤5-row payload heuristic.
769                    let tvf_result = RuntimeQueryResult {
770                        query: query.to_string(),
771                        mode,
772                        statement,
773                        engine: "runtime-graph-tvf",
774                        result: self.execute_table_function(&name, &args, &named_args)?,
775                        affected_rows: 0,
776                        statement_type: "select",
777                        bookmark: None,
778                        notice: None,
779                    };
780                    frame.write_result_cache(self, &tvf_result, result_cache_scopes.clone());
781                    return Ok(tvf_result);
782                }
783                // Inline-graph TVF (issue #799): the graph is supplied by two
784                // subqueries instead of a collection reference. Unlike the
785                // graph-collection form, the result IS cacheable — its cache
786                // key is the query string (the result-cache read at the top of
787                // `execute_query_inner` keys on it) and `result_cache_scopes`
788                // already carries the `nodes`/`edges` source collections, so a
789                // write to any of them invalidates the entry.
790                if let Some(TableSource::InlineGraphFunction {
791                    name,
792                    nodes,
793                    edges,
794                    named_args,
795                }) = table.source.clone()
796                {
797                    let inline_result = RuntimeQueryResult {
798                        query: query.to_string(),
799                        mode,
800                        statement,
801                        engine: "runtime-graph-tvf-inline",
802                        result: self.execute_inline_graph_function(
803                            &name,
804                            &nodes,
805                            &edges,
806                            &named_args,
807                        )?,
808                        affected_rows: 0,
809                        statement_type: "select",
810                        bookmark: None,
811                        notice: None,
812                    };
813                    frame.write_result_cache(self, &inline_result, result_cache_scopes);
814                    return Ok(inline_result);
815                }
816                if super::red_schema::is_virtual_table(&table.table) {
817                    return Ok(RuntimeQueryResult {
818                        query: query.to_string(),
819                        mode,
820                        statement,
821                        engine: "runtime-red-schema",
822                        result: super::red_schema::red_query(
823                            self,
824                            &table.table,
825                            &table,
826                            &frame as &dyn super::statement_frame::ReadFrame,
827                        )?,
828                        affected_rows: 0,
829                        statement_type: "select",
830                        bookmark: None,
831                        notice: None,
832                    });
833                }
834
835                // `<graph>.<output>` analytics virtual view (issue #800).
836                // Recomputed on demand — intentionally not result-cached, so it
837                // always reflects the current graph data.
838                if let Some(view_result) = self.try_resolve_analytics_view(
839                    &table,
840                    &frame as &dyn super::statement_frame::ReadFrame,
841                )? {
842                    return Ok(RuntimeQueryResult {
843                        query: query.to_string(),
844                        mode,
845                        statement,
846                        engine: "runtime-graph-analytics-view",
847                        result: view_result,
848                        affected_rows: 0,
849                        statement_type: "select",
850                        bookmark: None,
851                        notice: None,
852                    });
853                }
854
855                if let Some(result) = self.execute_probabilistic_select(&table)? {
856                    return Ok(RuntimeQueryResult {
857                        query: query.to_string(),
858                        mode,
859                        statement,
860                        engine: "runtime-probabilistic",
861                        result,
862                        affected_rows: 0,
863                        statement_type: "select",
864                        bookmark: None,
865                        notice: None,
866                    });
867                }
868
869                // Foreign-table intercept (Phase 3.2.2 PG parity).
870                //
871                // When the referenced table matches a `CREATE FOREIGN TABLE`
872                // registration, short-circuit into the FDW scan. Phase 3.2
873                // wrappers don't yet support pushdown, so filters/projections
874                // apply post-scan via `apply_foreign_table_filters` — good
875                // enough for correctness; perf work lands in 3.2.3.
876                if self.inner.foreign_tables.is_foreign_table(&table.table) {
877                    let records = self
878                        .inner
879                        .foreign_tables
880                        .scan(&table.table)
881                        .map_err(|e| RedDBError::Internal(e.to_string()))?;
882                    let result = apply_foreign_table_filters(records, &table);
883                    return Ok(RuntimeQueryResult {
884                        query: query.to_string(),
885                        mode,
886                        statement,
887                        engine: "runtime-fdw",
888                        result,
889                        affected_rows: 0,
890                        statement_type: "select",
891                        bookmark: None,
892                        notice: None,
893                    });
894                }
895
896                // Row-Level Security enforcement (Phase 2.5.2 PG parity).
897                //
898                // When RLS is enabled on this table, fetch every policy
899                // that applies to the current (role, SELECT) pair and
900                // fold them into the query's WHERE clause: policies
901                // OR-combine (any of them admitting the row is enough),
902                // then AND into the caller's existing filter.
903                //
904                // Anonymous callers (no thread-local identity) pass
905                // `role = None`; policies with a specific `TO role`
906                // clause skip, but `TO PUBLIC` policies still apply.
907                //
908                // When `inject_rls_filters` returns `None` the table has
909                // RLS enabled but no policy admits the caller's role —
910                // short-circuit with an empty result set instead of
911                // synthesising a contradiction filter.
912                let Some(table_with_rls) = self.authorize_relational_table_select(
913                    table,
914                    &frame as &dyn super::statement_frame::ReadFrame,
915                )?
916                else {
917                    let empty = crate::storage::query::unified::UnifiedResult::empty();
918                    return Ok(RuntimeQueryResult {
919                        query: query.to_string(),
920                        mode,
921                        statement,
922                        engine: "runtime-table-rls",
923                        result: empty,
924                        affected_rows: 0,
925                        statement_type: "select",
926                        bookmark: None,
927                        notice: None,
928                    });
929                };
930                Ok(RuntimeQueryResult {
931                    query: query.to_string(),
932                    mode,
933                    statement,
934                    engine: "runtime-table",
935                    // #885: lend the frame-owned row-buffer arena to the
936                    // streaming path so chunk buffers are reused across
937                    // this statement's chunk-fetches instead of allocated
938                    // fresh per chunk. This is the table-query dispatch
939                    // that receives a frame-owned arena; the prepared and
940                    // subquery dispatch paths keep `None` for the arena.
941                    result: execute_runtime_table_query_in(
942                        &self.inner.db,
943                        &table_with_rls,
944                        Some(&self.inner.index_store),
945                        Some(frame.row_arena()),
946                    )?,
947                    affected_rows: 0,
948                    statement_type: "select",
949                    bookmark: None,
950                    notice: None,
951                })
952            }
953            QueryExpr::Join(join) => {
954                // Fold per-table RLS filters into each `QueryExpr::Table`
955                // leaf of the join tree before executing. Without this
956                // the join executor scans both tables raw and ignores
957                // policies — a `WITHIN TENANT 'x'` against a join of
958                // two tenant-scoped tables would leak cross-tenant rows.
959                // When any leaf has RLS enabled and zero matching policy,
960                // short-circuit to an empty join result instead of
961                // emitting a contradiction filter.
962                let join_with_rls = match self.authorize_relational_join_select(
963                    join,
964                    &frame as &dyn super::statement_frame::ReadFrame,
965                )? {
966                    Some(j) => j,
967                    None => {
968                        return Ok(RuntimeQueryResult {
969                            query: query.to_string(),
970                            mode,
971                            statement,
972                            engine: "runtime-join-rls",
973                            result: crate::storage::query::unified::UnifiedResult::empty(),
974                            affected_rows: 0,
975                            statement_type: "select",
976                            bookmark: None,
977                            notice: None,
978                        });
979                    }
980                };
981                Ok(RuntimeQueryResult {
982                    query: query.to_string(),
983                    mode,
984                    statement,
985                    engine: "runtime-join",
986                    result: execute_runtime_join_query(self, &join_with_rls)?,
987                    affected_rows: 0,
988                    statement_type: "select",
989                    bookmark: None,
990                    notice: None,
991                })
992            }
993            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
994                query: query.to_string(),
995                mode,
996                statement,
997                engine: "runtime-vector",
998                result: execute_runtime_vector_query(self, &vector)?,
999                affected_rows: 0,
1000                statement_type: "select",
1001                bookmark: None,
1002                notice: None,
1003            }),
1004            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
1005                query: query.to_string(),
1006                mode,
1007                statement,
1008                engine: "runtime-hybrid",
1009                result: execute_runtime_hybrid_query(self, &hybrid)?,
1010                affected_rows: 0,
1011                statement_type: "select",
1012                bookmark: None,
1013                notice: None,
1014            }),
1015            QueryExpr::RankOf(ref rank) => self.execute_rank_of(query, rank),
1016            QueryExpr::ApproxRankOf(ref rank) => self.execute_approx_rank_of(query, rank),
1017            QueryExpr::RankRange(ref range) => self.execute_rank_range(query, range),
1018            // DML execution
1019            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
1020                Err(RedDBError::Query(
1021                    super::red_schema::READ_ONLY_ERROR.to_string(),
1022                ))
1023            }
1024            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
1025                Err(RedDBError::Query(
1026                    super::red_schema::READ_ONLY_ERROR.to_string(),
1027                ))
1028            }
1029            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
1030                Err(RedDBError::Query(
1031                    super::red_schema::READ_ONLY_ERROR.to_string(),
1032                ))
1033            }
1034            QueryExpr::Insert(ref insert) => self
1035                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
1036                    self.execute_insert(query, insert)
1037                }),
1038            QueryExpr::Update(ref update) => self
1039                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
1040                    self.execute_update(query, update)
1041                }),
1042            QueryExpr::Delete(ref delete) => self
1043                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
1044                    self.execute_delete(query, delete)
1045                }),
1046            // DDL execution
1047            QueryExpr::CreateTable(ref create) => self.execute_create_table(query, create),
1048            QueryExpr::CreateCollection(ref create) => {
1049                self.execute_create_collection(query, create)
1050            }
1051            QueryExpr::CreateVector(ref create) => self.execute_create_vector(query, create),
1052            QueryExpr::DropTable(ref drop_tbl) => self.execute_drop_table(query, drop_tbl),
1053            QueryExpr::DropGraph(ref drop_graph) => self.execute_drop_graph(query, drop_graph),
1054            QueryExpr::DropVector(ref drop_vector) => self.execute_drop_vector(query, drop_vector),
1055            QueryExpr::DropDocument(ref drop_document) => {
1056                self.execute_drop_document(query, drop_document)
1057            }
1058            QueryExpr::DropKv(ref drop_kv) => self.execute_drop_kv(query, drop_kv),
1059            QueryExpr::DropCollection(ref drop_collection) => {
1060                self.execute_drop_collection(query, drop_collection)
1061            }
1062            QueryExpr::Truncate(ref truncate) => self.execute_truncate(query, truncate),
1063            QueryExpr::AlterTable(ref alter) => self.execute_alter_table(query, alter),
1064            QueryExpr::CreateVcsRef(ref create) => self.execute_create_vcs_ref(query, create),
1065            QueryExpr::DropVcsRef(ref drop_ref) => self.execute_drop_vcs_ref(query, drop_ref),
1066            QueryExpr::ForkStore(ref fork) => self.execute_fork_store(query, fork),
1067            QueryExpr::PromoteFork(ref promote_fork) => {
1068                self.execute_promote_fork(query, promote_fork)
1069            }
1070            QueryExpr::DropFork(ref drop_fork) => self.execute_drop_fork(query, drop_fork),
1071            QueryExpr::ExplainAlter(ref explain) => self.execute_explain_alter(query, explain),
1072            // Graph analytics commands
1073            QueryExpr::GraphCommand(ref cmd) => self.execute_graph_command(query, cmd),
1074            // Search commands
1075            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query, cmd),
1076            // ASK: RAG query with LLM synthesis
1077            QueryExpr::Ask(ref ask) => self.execute_ask(query, ask),
1078            QueryExpr::CreateIndex(ref create_idx) => self.execute_create_index(query, create_idx),
1079            QueryExpr::DropIndex(ref drop_idx) => self.execute_drop_index(query, drop_idx),
1080            QueryExpr::ProbabilisticCommand(ref cmd) => {
1081                self.execute_probabilistic_command(query, cmd)
1082            }
1083            // Time-series DDL
1084            QueryExpr::CreateTimeSeries(ref ts) => self.execute_create_timeseries(query, ts),
1085            QueryExpr::CreateMetric(ref metric) => self.execute_create_metric(query, metric),
1086            QueryExpr::AlterMetric(ref alter) => self.execute_alter_metric(query, alter),
1087            QueryExpr::CreateSlo(ref slo) => self.execute_create_slo(query, slo),
1088            QueryExpr::DropTimeSeries(ref ts) => self.execute_drop_timeseries(query, ts),
1089            // Queue DDL and commands
1090            QueryExpr::CreateQueue(ref q) => self.execute_create_queue(query, q),
1091            QueryExpr::AlterQueue(ref q) => self.execute_alter_queue(query, q),
1092            QueryExpr::DropQueue(ref q) => self.execute_drop_queue(query, q),
1093            QueryExpr::QueueSelect(ref q) => self.execute_queue_select(query, q),
1094            QueryExpr::QueueCommand(ref cmd) => self
1095                .with_deferred_store_wal_if_transaction(|| self.execute_queue_command(query, cmd)),
1096            QueryExpr::EventsBackfill(ref backfill) => {
1097                self.execute_events_backfill(query, backfill)
1098            }
1099            QueryExpr::EventsBackfillStatus { ref collection } => Err(RedDBError::Query(format!(
1100                "EVENTS BACKFILL STATUS for '{collection}' is not implemented in this slice"
1101            ))),
1102            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query, cmd),
1103            QueryExpr::ConfigCommand(ref cmd) => self.execute_config_command(query, cmd),
1104            QueryExpr::CreateTree(ref tree) => self.execute_create_tree(query, tree),
1105            QueryExpr::DropTree(ref tree) => self.execute_drop_tree(query, tree),
1106            QueryExpr::TreeCommand(ref cmd) => self.execute_tree_command(query, cmd),
1107            // SET CONFIG key = value
1108            QueryExpr::SetConfig { ref key, ref value } => {
1109                if key.starts_with("red.secret.") {
1110                    return Err(RedDBError::Query(
1111                        "red.secret.* is reserved for vault secrets; use SET SECRET".to_string(),
1112                    ));
1113                }
1114                if key.starts_with("red.secrets.") {
1115                    return Err(RedDBError::Query(
1116                        "red.secrets.* is reserved for vault secrets; use SET SECRET".to_string(),
1117                    ));
1118                }
1119                // ADR-0068 §5 clean break: reject the removed AI config keys
1120                // (old `default.*` provider/model and per-alias base_url shape)
1121                // with a didactic error naming the replacement key.
1122                crate::ai::validate_ai_config_key_on_write(key)?;
1123                // The managed-config guardrail runs first: it owns the
1124                // didactic error and the `config:write` control event for
1125                // registry-managed keys. The generic capability gate below
1126                // then covers every other key.
1127                match self.check_managed_config_write_for_set_config(key) {
1128                    Err(err) => Err(err),
1129                    Ok(()) => {
1130                        self.check_config_write_privilege(key)?;
1131                        let store = self.inner.db.store();
1132                        let json_val = match value {
1133                            Value::Text(s) => crate::serde_json::Value::String(s.to_string()),
1134                            Value::Integer(n) => crate::serde_json::Value::Number(*n as f64),
1135                            Value::Float(n) => crate::serde_json::Value::Number(*n),
1136                            Value::Boolean(b) => crate::serde_json::Value::Bool(*b),
1137                            _ => crate::serde_json::Value::String(value.to_string()),
1138                        };
1139                        store.set_config_tree(key, &json_val);
1140                        update_current_config_value(key, value.clone());
1141                        // Config changes can flip runtime behavior mid-session
1142                        // (auto_decrypt, auto_encrypt, etc.) — invalidate the
1143                        // result cache so subsequent reads re-execute against
1144                        // the new config.
1145                        self.invalidate_result_cache();
1146                        Ok(RuntimeQueryResult::ok_message(
1147                            query.to_string(),
1148                            &format!("config set: {key}"),
1149                            "set",
1150                        ))
1151                    }
1152                }
1153            }
1154            // SET SECRET key = value
1155            QueryExpr::SetSecret { ref key, ref value } => {
1156                if key.starts_with("red.config.") {
1157                    return Err(RedDBError::Query(
1158                        "red.config.* is reserved for config; use SET CONFIG".to_string(),
1159                    ));
1160                }
1161                reject_engine_internal_vault_key(key)?;
1162                if key.starts_with("red.secret.") {
1163                    self.set_internal_provider_secret(
1164                        key,
1165                        if value.is_null() {
1166                            None
1167                        } else {
1168                            Some(secret_sql_value_to_string(value)?)
1169                        },
1170                    )?;
1171                    return Ok(RuntimeQueryResult::ok_message(
1172                        query.to_string(),
1173                        &format!("secret set: {key}"),
1174                        "set_secret",
1175                    ));
1176                }
1177                let value = if value.is_null() {
1178                    None
1179                } else {
1180                    Some(secret_sql_value_to_string(value)?)
1181                };
1182                self.set_user_secret(key, value.clone())?;
1183                update_current_secret_value(key, value);
1184                Ok(RuntimeQueryResult::ok_message(
1185                    query.to_string(),
1186                    &format!("secret set: {key}"),
1187                    "set_secret",
1188                ))
1189            }
1190            // DELETE SECRET key
1191            QueryExpr::DeleteSecret { ref key } => {
1192                reject_engine_internal_vault_key(key)?;
1193                if key.starts_with("red.secret.") {
1194                    self.set_internal_provider_secret(key, None)?;
1195                    return Ok(RuntimeQueryResult::ok_message(
1196                        query.to_string(),
1197                        &format!("secret deleted: {key}"),
1198                        "delete_secret",
1199                    ));
1200                }
1201                self.set_user_secret(key, None)?;
1202                update_current_secret_value(key, None);
1203                Ok(RuntimeQueryResult::ok_message(
1204                    query.to_string(),
1205                    &format!("secret deleted: {key}"),
1206                    "delete_secret",
1207                ))
1208            }
1209            // SET KV key = value — plain (non-encrypted) user KV entry (#1602)
1210            QueryExpr::SetKv { ref key, ref value } => {
1211                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1212                    RedDBError::Query("SET KV requires an auth store".to_string())
1213                })?;
1214                self.check_kv_write_privilege(&auth_store, key)?;
1215                // `SET KV k = NULL` deletes, mirroring `SET SECRET`.
1216                if matches!(value, Value::Null) {
1217                    auth_store.plain_kv_delete(key);
1218                    update_current_kv_value(key, None);
1219                    self.invalidate_result_cache();
1220                    return Ok(RuntimeQueryResult::ok_message(
1221                        query.to_string(),
1222                        &format!("kv deleted: {key}"),
1223                        "delete_kv",
1224                    ));
1225                }
1226                let value = secret_sql_value_to_string(value)?;
1227                auth_store.plain_kv_set(key.clone(), value.clone());
1228                update_current_kv_value(key, Some(value));
1229                self.invalidate_result_cache();
1230                Ok(RuntimeQueryResult::ok_message(
1231                    query.to_string(),
1232                    &format!("kv set: {key}"),
1233                    "set_kv",
1234                ))
1235            }
1236            // DELETE KV key
1237            QueryExpr::DeleteKv { ref key } => {
1238                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1239                    RedDBError::Query("DELETE KV requires an auth store".to_string())
1240                })?;
1241                self.check_kv_write_privilege(&auth_store, key)?;
1242                let deleted = auth_store.plain_kv_delete(key);
1243                if deleted {
1244                    update_current_kv_value(key, None);
1245                }
1246                self.invalidate_result_cache();
1247                Ok(RuntimeQueryResult::ok_message(
1248                    query.to_string(),
1249                    &format!("kv deleted: {key}"),
1250                    if deleted {
1251                        "delete_kv"
1252                    } else {
1253                        "delete_kv_not_found"
1254                    },
1255                ))
1256            }
1257            QueryExpr::Scrub { background, budget } => {
1258                self.execute_scrub_query(query, mode, background, budget)
1259            }
1260            // SHOW SECRET[S] [prefix]
1261            QueryExpr::ShowSecrets { ref prefix } => {
1262                let result = self.show_user_secrets(prefix.as_deref())?;
1263                Ok(RuntimeQueryResult {
1264                    query: query.to_string(),
1265                    mode,
1266                    statement: "show_secrets",
1267                    engine: "runtime-secret",
1268                    result,
1269                    affected_rows: 0,
1270                    statement_type: "select",
1271                    bookmark: None,
1272                    notice: None,
1273                })
1274            }
1275            // SHOW CONFIG [prefix] [AS JSON|FORMAT JSON]
1276            QueryExpr::ShowConfig {
1277                ref prefix,
1278                as_json,
1279            } => {
1280                self.check_config_read_privilege()?;
1281                let store = self.inner.db.store();
1282                let all_collections = store.list_collections();
1283                if !all_collections.contains(&"red_config".to_string()) {
1284                    if as_json {
1285                        return Ok(show_config_json_result(
1286                            query,
1287                            mode,
1288                            prefix,
1289                            crate::serde_json::Value::Object(crate::serde_json::Map::new()),
1290                        ));
1291                    }
1292                    let result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1293                    return Ok(RuntimeQueryResult {
1294                        query: query.to_string(),
1295                        mode,
1296                        statement: "show_config",
1297                        engine: "runtime-config",
1298                        result,
1299                        affected_rows: 0,
1300                        statement_type: "select",
1301                        bookmark: None,
1302                        notice: None,
1303                    });
1304                }
1305                let manager = store
1306                    .get_collection("red_config")
1307                    .ok_or_else(|| RedDBError::NotFound("red_config".to_string()))?;
1308                let entities = manager.query_all(|_| true);
1309                let mut latest = std::collections::BTreeMap::<String, (u64, Value, Value)>::new();
1310                for entity in entities {
1311                    if let EntityData::Row(ref row) = entity.data {
1312                        if let Some(ref named) = row.named {
1313                            let key_val = named.get("key").cloned().unwrap_or(Value::Null);
1314                            let val = named.get("value").cloned().unwrap_or(Value::Null);
1315                            let key_str = match &key_val {
1316                                Value::Text(s) => s.as_ref(),
1317                                _ => continue,
1318                            };
1319                            if key_str == super::function_catalog::REGISTRY_KEY {
1320                                continue;
1321                            }
1322                            if let Some(ref pfx) = prefix {
1323                                if !key_str.starts_with(pfx.as_str()) {
1324                                    continue;
1325                                }
1326                            }
1327                            let entity_id = entity.id.raw();
1328                            match latest.get(key_str) {
1329                                Some((prev_id, _, _)) if *prev_id > entity_id => {}
1330                                _ => {
1331                                    latest.insert(key_str.to_string(), (entity_id, key_val, val));
1332                                }
1333                            }
1334                        }
1335                    }
1336                }
1337                if as_json {
1338                    let mut tree = crate::serde_json::Value::Object(crate::serde_json::Map::new());
1339                    for (key, (_, _, val)) in latest {
1340                        let relative = match prefix {
1341                            Some(pfx) if key == *pfx => "",
1342                            Some(pfx) => key
1343                                .strip_prefix(pfx.as_str())
1344                                .and_then(|tail| tail.strip_prefix('.'))
1345                                .unwrap_or(key.as_str()),
1346                            None => key.as_str(),
1347                        };
1348                        insert_config_json_path(&mut tree, relative, val.to_json());
1349                    }
1350                    return Ok(show_config_json_result(query, mode, prefix, tree));
1351                }
1352                let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1353                for (_, key_val, val) in latest.into_values() {
1354                    let mut record = UnifiedRecord::new();
1355                    record.set("key", key_val);
1356                    record.set("value", val);
1357                    result.push(record);
1358                }
1359                Ok(RuntimeQueryResult {
1360                    query: query.to_string(),
1361                    mode,
1362                    statement: "show_config",
1363                    engine: "runtime-config",
1364                    result,
1365                    affected_rows: 0,
1366                    statement_type: "select",
1367                    bookmark: None,
1368                    notice: None,
1369                })
1370            }
1371            // Session-local multi-tenancy handle (Phase 2.5.3).
1372            //
1373            // SET TENANT 'id' / SET TENANT NULL / RESET TENANT — writes
1374            // the thread-local; SHOW TENANT returns it. Paired with the
1375            // CURRENT_TENANT() scalar for use in RLS policies.
1376            QueryExpr::SetTenant(ref value) => {
1377                self.check_set_tenant_privilege(value.as_deref())?;
1378                match value {
1379                    Some(id) => set_current_tenant(id.clone()),
1380                    None => clear_current_tenant(),
1381                }
1382                Ok(RuntimeQueryResult::ok_message(
1383                    query.to_string(),
1384                    &match value {
1385                        Some(id) => format!("tenant set: {id}"),
1386                        None => "tenant cleared".to_string(),
1387                    },
1388                    "set_tenant",
1389                ))
1390            }
1391            QueryExpr::ShowTenant => {
1392                let mut result = UnifiedResult::with_columns(vec!["tenant".into()]);
1393                let mut record = UnifiedRecord::new();
1394                record.set(
1395                    "tenant",
1396                    current_tenant().map(Value::text).unwrap_or(Value::Null),
1397                );
1398                result.push(record);
1399                Ok(RuntimeQueryResult {
1400                    query: query.to_string(),
1401                    mode,
1402                    statement: "show_tenant",
1403                    engine: "runtime-tenant",
1404                    result,
1405                    affected_rows: 0,
1406                    statement_type: "select",
1407                    bookmark: None,
1408                    notice: None,
1409                })
1410            }
1411            // Transaction control (Phase 2.3 PG parity).
1412            //
1413            // BEGIN allocates a real `Xid` and stores a `TxnContext` keyed by
1414            // the current connection's id. COMMIT/ROLLBACK release it through
1415            // the `SnapshotManager` so future snapshots see the correct set of
1416            // active/aborted transactions.
1417            //
1418            // Tuple stamping (xmin/xmax) and read-path visibility filtering
1419            // land in Phase 2.3.2 — this dispatch only manages the snapshot
1420            // registry. Statements running outside a TxnContext still behave
1421            // as autocommit (xid=0 → visible to every snapshot).
1422            QueryExpr::TransactionControl(ref ctl) => {
1423                use crate::storage::transaction::IsolationLevel;
1424                use reddb_rql::ast::TxnControl;
1425
1426                // Phase 2.3 keys transactions by a thread-local connection id.
1427                // The stdio/gRPC paths wire a real per-connection id later;
1428                // for embedded use (one RedDBRuntime per process-ish caller)
1429                // we fall back to a deterministic placeholder.
1430                let conn_id = current_connection_id();
1431
1432                let (kind, msg) = match ctl {
1433                    TxnControl::Begin(requested_isolation) => {
1434                        if self.inner.transaction_state.in_transaction(conn_id) {
1435                            return Err(RedDBError::Query("transaction already active".into()));
1436                        }
1437                        let isolation = requested_isolation
1438                            .map(IsolationLevel::from)
1439                            .unwrap_or(IsolationLevel::SnapshotIsolation);
1440                        let xid = self.inner.transaction_state.begin(conn_id, isolation);
1441                        ("begin", format!("BEGIN — xid={xid} (snapshot isolation)"))
1442                    }
1443                    TxnControl::Commit => {
1444                        let has_vault_writes = self
1445                            .inner
1446                            .pending_vault_writes
1447                            .read()
1448                            .get(&conn_id)
1449                            .is_some_and(|writes| !writes.is_empty());
1450                        let vault_store = self.inner.db.store();
1451                        let _vault_guard =
1452                            has_vault_writes.then(|| vault_store.vault_publication_lock.lock());
1453                        let context = self.inner.transaction_state.commit(conn_id, |ctx| {
1454                            let mut own_xids = std::collections::HashSet::new();
1455                            own_xids.insert(ctx.xid);
1456                            for (_, sub) in &ctx.savepoints {
1457                                own_xids.insert(*sub);
1458                            }
1459                            for sub in &ctx.released_sub_xids {
1460                                own_xids.insert(*sub);
1461                            }
1462                            if let Err(err) = self.check_table_row_write_conflicts(
1463                                conn_id,
1464                                &ctx.snapshot,
1465                                &own_xids,
1466                                ctx.isolation,
1467                            ) {
1468                                let undo = self.revive_pending_versioned_updates(conn_id);
1469                                self.revive_pending_tombstones(conn_id);
1470                                self.discard_pending_kv_watch_events(conn_id);
1471                                self.discard_pending_queue_wakes(conn_id);
1472                                self.discard_pending_store_wal_actions(conn_id);
1473                                self.release_pending_claim_locks(conn_id);
1474                                undo?;
1475                                return Err(err);
1476                            }
1477                            if let Err(err) = self.check_queue_dedup_write_conflicts(
1478                                conn_id,
1479                                &ctx.snapshot,
1480                                &own_xids,
1481                            ) {
1482                                let undo = self.revive_pending_versioned_updates(conn_id);
1483                                self.revive_pending_tombstones(conn_id);
1484                                self.discard_pending_queue_dedup(conn_id);
1485                                self.discard_pending_kv_watch_events(conn_id);
1486                                self.discard_pending_queue_wakes(conn_id);
1487                                self.discard_pending_store_wal_actions(conn_id);
1488                                self.release_pending_claim_locks(conn_id);
1489                                undo?;
1490                                return Err(err);
1491                            }
1492                            if let Err(err) = self.prepare_pending_vault_writes(conn_id, ctx) {
1493                                let undo = self.revive_pending_versioned_updates(conn_id);
1494                                self.revive_pending_tombstones(conn_id);
1495                                self.discard_pending_queue_dedup(conn_id);
1496                                self.discard_pending_kv_watch_events(conn_id);
1497                                self.discard_pending_queue_wakes(conn_id);
1498                                self.discard_pending_store_wal_actions(conn_id);
1499                                self.release_pending_claim_locks(conn_id);
1500                                undo?;
1501                                return Err(err);
1502                            }
1503                            self.restore_pending_write_stamps(conn_id);
1504                            if let Err(err) = self.flush_pending_store_wal_actions(conn_id) {
1505                                let undo = self.revive_pending_versioned_updates(conn_id);
1506                                self.revive_pending_tombstones(conn_id);
1507                                self.discard_pending_queue_dedup(conn_id);
1508                                self.discard_pending_kv_watch_events(conn_id);
1509                                self.discard_pending_queue_wakes(conn_id);
1510                                self.release_pending_claim_locks(conn_id);
1511                                undo?;
1512                                return Err(err);
1513                            }
1514                            Ok(())
1515                        });
1516                        if context.is_err() {
1517                            self.discard_pending_vault_writes(conn_id)?;
1518                        }
1519                        let context = context?;
1520                        match context {
1521                            Some(ctx) => {
1522                                self.finalize_pending_vault_writes(conn_id);
1523                                self.finalize_pending_versioned_updates(conn_id);
1524                                self.finalize_pending_tombstones(conn_id);
1525                                self.finalize_pending_queue_dedup(conn_id);
1526                                self.finalize_pending_kv_watch_events(conn_id);
1527                                self.finalize_pending_queue_wakes(conn_id);
1528                                self.release_pending_claim_locks(conn_id);
1529                                ("commit", format!("COMMIT — xid={} committed", ctx.xid))
1530                            }
1531                            None => (
1532                                "commit",
1533                                "COMMIT outside transaction — no-op (autocommit)".to_string(),
1534                            ),
1535                        }
1536                    }
1537                    TxnControl::Rollback => match self.rollback_connection(conn_id)? {
1538                        Some(ctx) => ("rollback", format!("ROLLBACK — xid={} aborted", ctx.xid)),
1539                        None => (
1540                            "rollback",
1541                            "ROLLBACK outside transaction — no-op (autocommit)".to_string(),
1542                        ),
1543                    },
1544                    // Phase 2.3.2e: savepoints map onto sub-xids. Each
1545                    // SAVEPOINT allocates a fresh xid and pushes it
1546                    // onto the per-txn stack so subsequent writes can
1547                    // be selectively rolled back. RELEASE pops without
1548                    // aborting; ROLLBACK TO aborts the sub-xid (and
1549                    // any nested ones) + revives their tombstones.
1550                    TxnControl::Savepoint(name) => {
1551                        match self.inner.transaction_state.savepoint(conn_id, name) {
1552                            Some(sub) => ("savepoint", format!("SAVEPOINT {name} — sub_xid={sub}")),
1553                            None => (
1554                                "savepoint",
1555                                "SAVEPOINT outside transaction — no-op".to_string(),
1556                            ),
1557                        }
1558                    }
1559                    TxnControl::ReleaseSavepoint(name) => {
1560                        match self
1561                            .inner
1562                            .transaction_state
1563                            .release_savepoint(conn_id, name)?
1564                        {
1565                            Some(released) => (
1566                                "release_savepoint",
1567                                format!("RELEASE SAVEPOINT {name} — {released} level(s)"),
1568                            ),
1569                            None => (
1570                                "release_savepoint",
1571                                "RELEASE outside transaction — no-op".to_string(),
1572                            ),
1573                        }
1574                    }
1575                    TxnControl::RollbackToSavepoint(name) => {
1576                        match self
1577                            .inner
1578                            .transaction_state
1579                            .rollback_to_savepoint(conn_id, name)?
1580                        {
1581                            Some(rollback) => {
1582                                self.rollback_vault_savepoint(conn_id, rollback.savepoint_xid);
1583                                let reverted_updates = self.revive_versioned_updates_since(
1584                                    conn_id,
1585                                    rollback.savepoint_xid,
1586                                );
1587                                let revived =
1588                                    self.revive_tombstones_since(conn_id, rollback.savepoint_xid);
1589                                let reverted_updates = reverted_updates?;
1590                                (
1591                                    "rollback_to_savepoint",
1592                                    format!(
1593                                        "ROLLBACK TO SAVEPOINT {name} — aborted {} sub_xid(s), reverted {reverted_updates} update(s), revived {revived} tombstone(s)",
1594                                        rollback.aborted_xids.len(),
1595                                    ),
1596                                )
1597                            }
1598                            None => (
1599                                "rollback_to_savepoint",
1600                                "ROLLBACK TO outside transaction — no-op".to_string(),
1601                            ),
1602                        }
1603                    }
1604                };
1605                Ok(RuntimeQueryResult::ok_message(
1606                    query.to_string(),
1607                    &msg,
1608                    kind,
1609                ))
1610            }
1611            // Schema + Sequence DDL (Phase 1.3 PG parity).
1612            //
1613            // Schemas are lightweight logical namespaces: a CREATE SCHEMA call
1614            // just registers the name in `red_config` under `schema.{name}`.
1615            // Table lookups still happen by collection name; clients using
1616            // `schema.table` qualified names collapse to collection `schema.table`.
1617            //
1618            // Sequences persist a 64-bit counter + metadata (start, increment)
1619            // in `red_config` under `sequence.{name}.*`. Scalar callers
1620            // `nextval('name')` / `currval('name')` arrive with the MVCC phase
1621            // once we have a proper mutating-function dispatch path; for now the
1622            // DDL just establishes the catalog entry so clients don't error.
1623            QueryExpr::CreateSchema(ref q) => {
1624                let store = self.inner.db.store();
1625                let key = format!("schema.{}", q.name);
1626                if store.get_config(&key).is_some() {
1627                    if q.if_not_exists {
1628                        return Ok(RuntimeQueryResult::ok_message(
1629                            query.to_string(),
1630                            &format!("schema {} already exists — skipped", q.name),
1631                            "create_schema",
1632                        ));
1633                    }
1634                    return Err(RedDBError::Internal(format!(
1635                        "schema {} already exists",
1636                        q.name
1637                    )));
1638                }
1639                store.set_config_tree(&key, &crate::serde_json::Value::Bool(true));
1640                Ok(RuntimeQueryResult::ok_message(
1641                    query.to_string(),
1642                    &format!("schema {} created", q.name),
1643                    "create_schema",
1644                ))
1645            }
1646            QueryExpr::DropSchema(ref q) => {
1647                let store = self.inner.db.store();
1648                let key = format!("schema.{}", q.name);
1649                let existed = store.get_config(&key).is_some();
1650                if !existed && !q.if_exists {
1651                    return Err(RedDBError::Internal(format!(
1652                        "schema {} does not exist",
1653                        q.name
1654                    )));
1655                }
1656                // Remove marker from red_config via set to null.
1657                store.set_config_tree(&key, &crate::serde_json::Value::Null);
1658                let suffix = if q.cascade {
1659                    " (CASCADE accepted — tables untouched)"
1660                } else {
1661                    ""
1662                };
1663                Ok(RuntimeQueryResult::ok_message(
1664                    query.to_string(),
1665                    &format!("schema {} dropped{}", q.name, suffix),
1666                    "drop_schema",
1667                ))
1668            }
1669            QueryExpr::CreateSequence(ref q) => {
1670                let store = self.inner.db.store();
1671                let base = format!("sequence.{}", q.name);
1672                let start_key = format!("{base}.start");
1673                let incr_key = format!("{base}.increment");
1674                let curr_key = format!("{base}.current");
1675                if store.get_config(&start_key).is_some() {
1676                    if q.if_not_exists {
1677                        return Ok(RuntimeQueryResult::ok_message(
1678                            query.to_string(),
1679                            &format!("sequence {} already exists — skipped", q.name),
1680                            "create_sequence",
1681                        ));
1682                    }
1683                    return Err(RedDBError::Internal(format!(
1684                        "sequence {} already exists",
1685                        q.name
1686                    )));
1687                }
1688                // Persist start + increment, and set current so the first
1689                // nextval returns `start`.
1690                let initial_current = q.start - q.increment;
1691                store.set_config_tree(
1692                    &start_key,
1693                    &crate::serde_json::Value::Number(q.start as f64),
1694                );
1695                store.set_config_tree(
1696                    &incr_key,
1697                    &crate::serde_json::Value::Number(q.increment as f64),
1698                );
1699                store.set_config_tree(
1700                    &curr_key,
1701                    &crate::serde_json::Value::Number(initial_current as f64),
1702                );
1703                Ok(RuntimeQueryResult::ok_message(
1704                    query.to_string(),
1705                    &format!(
1706                        "sequence {} created (start={}, increment={})",
1707                        q.name, q.start, q.increment
1708                    ),
1709                    "create_sequence",
1710                ))
1711            }
1712            QueryExpr::DropSequence(ref q) => {
1713                let store = self.inner.db.store();
1714                let base = format!("sequence.{}", q.name);
1715                let existed = store.get_config(&format!("{base}.start")).is_some();
1716                if !existed && !q.if_exists {
1717                    return Err(RedDBError::Internal(format!(
1718                        "sequence {} does not exist",
1719                        q.name
1720                    )));
1721                }
1722                for k in ["start", "increment", "current"] {
1723                    store.set_config_tree(&format!("{base}.{k}"), &crate::serde_json::Value::Null);
1724                }
1725                Ok(RuntimeQueryResult::ok_message(
1726                    query.to_string(),
1727                    &format!("sequence {} dropped", q.name),
1728                    "drop_sequence",
1729                ))
1730            }
1731            // Views — CREATE [MATERIALIZED] VIEW (Phase 2.1 PG parity).
1732            //
1733            // The view definition is stored in-memory on RuntimeInner (not
1734            // persisted). SELECTs that reference the view name will substitute
1735            // the stored `QueryExpr` via `resolve_view_reference` during
1736            // planning (same entry point used by table-name resolution).
1737            //
1738            // Materialized views additionally allocate a slot in
1739            // `MaterializedViewCache`; a REFRESH repopulates that slot.
1740            QueryExpr::CreateView(ref q) => {
1741                let mut views = self.inner.views.write();
1742                if views.contains_key(&q.name) && !q.or_replace {
1743                    if q.if_not_exists {
1744                        return Ok(RuntimeQueryResult::ok_message(
1745                            query.to_string(),
1746                            &format!("view {} already exists — skipped", q.name),
1747                            "create_view",
1748                        ));
1749                    }
1750                    return Err(RedDBError::Internal(format!(
1751                        "view {} already exists",
1752                        q.name
1753                    )));
1754                }
1755                views.insert(q.name.clone(), Arc::new(q.clone()));
1756                drop(views);
1757
1758                // Materialized view: register cache slot (data is empty until REFRESH).
1759                if q.materialized {
1760                    use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
1761                    let refresh = match q.refresh_every_ms {
1762                        Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
1763                        None => RefreshPolicy::Manual,
1764                    };
1765                    let dependencies = collect_table_refs(&q.query);
1766                    let def = MaterializedViewDef {
1767                        name: q.name.clone(),
1768                        query: format!("<parsed view {}>", q.name),
1769                        dependencies: dependencies.clone(),
1770                        refresh,
1771                        retention_duration_ms: q.retention_duration_ms,
1772                    };
1773                    self.inner.materialized_views.write().register(def);
1774
1775                    // Issue #593 slice 9a — persist the descriptor to
1776                    // the system catalog so the definition survives a
1777                    // restart. Upsert semantics (delete-then-insert by
1778                    // name) keep the catalog free of duplicate rows
1779                    // across `CREATE OR REPLACE` churn.
1780                    let descriptor =
1781                        crate::runtime::continuous_materialized_view::MaterializedViewDescriptor {
1782                            name: q.name.clone(),
1783                            source_sql: query.to_string(),
1784                            source_collections: dependencies,
1785                            refresh_every_ms: q.refresh_every_ms,
1786                            retention_duration_ms: q.retention_duration_ms,
1787                        };
1788                    let store = self.inner.db.store();
1789                    crate::runtime::continuous_materialized_view::persist_descriptor(
1790                        store.as_ref(),
1791                        &descriptor,
1792                    )?;
1793
1794                    // Issue #594 slice 9b — provision a Table-shaped
1795                    // backing collection named after the view. The
1796                    // rewriter skips materialized views (see
1797                    // `rewrite_view_refs_inner`) so `SELECT FROM v`
1798                    // resolves to this collection directly. Empty
1799                    // until REFRESH wires through it in 9c.
1800                    self.ensure_materialized_view_backing(&q.name)?;
1801                }
1802                // Plan cache may have cached a plan that didn't know about this
1803                // view — invalidate so future references pick up the new binding.
1804                // Result cache gets flushed too: OR REPLACE must not serve a
1805                // prior execution of the obsolete body.
1806                self.invalidate_plan_cache();
1807                self.invalidate_result_cache();
1808
1809                Ok(RuntimeQueryResult::ok_message(
1810                    query.to_string(),
1811                    &format!(
1812                        "{}view {} created",
1813                        if q.materialized { "materialized " } else { "" },
1814                        q.name
1815                    ),
1816                    "create_view",
1817                ))
1818            }
1819            QueryExpr::DropView(ref q) => {
1820                let mut views = self.inner.views.write();
1821                let removed = views.remove(&q.name);
1822                let existed = removed.is_some();
1823                let removed_materialized =
1824                    removed.as_ref().map(|v| v.materialized).unwrap_or(false);
1825                drop(views);
1826                if q.materialized || existed {
1827                    // Try the materialised cache too — silent if absent.
1828                    self.inner.materialized_views.write().remove(&q.name);
1829                    // Issue #593 slice 9a — remove any persisted
1830                    // catalog row. Idempotent: a no-op when the view
1831                    // was never materialized (no row was ever written).
1832                    let store = self.inner.db.store();
1833                    crate::runtime::continuous_materialized_view::remove_by_name(
1834                        store.as_ref(),
1835                        &q.name,
1836                    )?;
1837                }
1838                // Issue #594 slice 9b — drop the backing collection
1839                // that was provisioned at CREATE time. Only mat views
1840                // ever had one; regular views never did.
1841                if removed_materialized || q.materialized {
1842                    self.drop_materialized_view_backing(&q.name)?;
1843                }
1844                // Drop any plan / result cache entries that baked the
1845                // view body into their QueryExpr.
1846                self.invalidate_plan_cache();
1847                self.invalidate_result_cache();
1848                if !existed && !q.if_exists {
1849                    return Err(RedDBError::Internal(format!(
1850                        "view {} does not exist",
1851                        q.name
1852                    )));
1853                }
1854                self.invalidate_plan_cache();
1855                Ok(RuntimeQueryResult::ok_message(
1856                    query.to_string(),
1857                    &format!("view {} dropped", q.name),
1858                    "drop_view",
1859                ))
1860            }
1861            QueryExpr::RefreshMaterializedView(ref q) => {
1862                // Look up the view definition, execute its underlying query,
1863                // and stash the serialized result in the materialised cache.
1864                let view = {
1865                    let views = self.inner.views.read();
1866                    views.get(&q.name).cloned()
1867                };
1868                let view = match view {
1869                    Some(v) => v,
1870                    None => {
1871                        return Err(RedDBError::Internal(format!(
1872                            "view {} does not exist",
1873                            q.name
1874                        )))
1875                    }
1876                };
1877                if !view.materialized {
1878                    return Err(RedDBError::Internal(format!(
1879                        "view {} is not materialized — REFRESH requires \
1880                         CREATE MATERIALIZED VIEW",
1881                        q.name
1882                    )));
1883                }
1884                // Execute the underlying query fresh.
1885                let started = std::time::Instant::now();
1886                let now_ms = std::time::SystemTime::now()
1887                    .duration_since(std::time::UNIX_EPOCH)
1888                    .map(|d| d.as_millis() as u64)
1889                    .unwrap_or(0);
1890                match self.execute_query_expr((*view.query).clone()) {
1891                    Ok(inner_result) => {
1892                        // Issue #595 slice 9c — atomically replace the
1893                        // backing collection's contents under a single
1894                        // WAL group. Concurrent SELECT from the view
1895                        // sees either the prior or new contents, never
1896                        // partial. A crash before the WAL commit lands
1897                        // leaves the prior contents intact on recovery.
1898                        let entities =
1899                            view_records_to_entities(&q.name, &inner_result.result.records);
1900                        let row_count = entities.len() as u64;
1901                        let store = self.inner.db.store();
1902                        let serialized_records = match store.refresh_collection(&q.name, entities) {
1903                            Ok(records) => records,
1904                            Err(err) => {
1905                                let duration_ms = started.elapsed().as_millis() as u64;
1906                                let msg = err.to_string();
1907                                self.inner
1908                                    .materialized_views
1909                                    .write()
1910                                    .record_refresh_failure(
1911                                        &q.name,
1912                                        msg.clone(),
1913                                        duration_ms,
1914                                        now_ms,
1915                                    );
1916                                return Err(RedDBError::Internal(format!(
1917                                    "REFRESH MATERIALIZED VIEW {}: {msg}",
1918                                    q.name
1919                                )));
1920                            }
1921                        };
1922
1923                        // Issue #596 slice 9d — emit a Refresh
1924                        // ChangeRecord into the logical-WAL spool so
1925                        // replicas deterministically replay the same
1926                        // backing-collection contents via
1927                        // `LogicalChangeApplier::apply_record`.
1928                        if let Some(ref primary) = self.inner.db.replication {
1929                            let lsn = self.inner.cdc.emit(
1930                                crate::replication::cdc::ChangeOperation::Refresh,
1931                                &q.name,
1932                                0,
1933                                "refresh",
1934                            );
1935                            self.invalidate_result_cache_for_table(&q.name);
1936                            let timestamp = std::time::SystemTime::now()
1937                                .duration_since(std::time::UNIX_EPOCH)
1938                                .unwrap_or_default()
1939                                .as_millis() as u64;
1940                            let record = ChangeRecord::for_refresh(
1941                                lsn,
1942                                timestamp,
1943                                q.name.clone(),
1944                                serialized_records,
1945                            )
1946                            .with_term(self.current_replication_term());
1947                            let encoded = record.encode();
1948                            primary.append_logical_record(record.lsn, encoded);
1949                        }
1950
1951                        let duration_ms = started.elapsed().as_millis() as u64;
1952                        let serialized = format!("{:?}", inner_result.result);
1953                        self.inner
1954                            .materialized_views
1955                            .write()
1956                            .record_refresh_success(
1957                                &q.name,
1958                                serialized.into_bytes(),
1959                                row_count,
1960                                duration_ms,
1961                                now_ms,
1962                            );
1963                        // SELECT FROM v now reads through the rewriter
1964                        // skip into the backing collection — drop the
1965                        // result cache so prior empty-backing reads
1966                        // don't shadow the new contents.
1967                        self.invalidate_result_cache();
1968                        Ok(RuntimeQueryResult::ok_message(
1969                            query.to_string(),
1970                            &format!("materialized view {} refreshed", q.name),
1971                            "refresh_materialized_view",
1972                        ))
1973                    }
1974                    Err(err) => {
1975                        let duration_ms = started.elapsed().as_millis() as u64;
1976                        let msg = err.to_string();
1977                        self.inner
1978                            .materialized_views
1979                            .write()
1980                            .record_refresh_failure(&q.name, msg.clone(), duration_ms, now_ms);
1981                        Err(err)
1982                    }
1983                }
1984            }
1985            // Row Level Security (Phase 2.5 PG parity).
1986            //
1987            // Policies live in an in-memory registry keyed by (table, name).
1988            // Enforcement (AND-ing the policy's USING clause into every
1989            // query's WHERE for the table) arrives in Phase 2.5.2 via the
1990            // filter compiler; this dispatch only manages the catalog.
1991            QueryExpr::CreatePolicy(ref q) => {
1992                let key = (q.table.clone(), q.name.clone());
1993                self.inner
1994                    .rls_policies
1995                    .write()
1996                    .insert(key, Arc::new(q.clone()));
1997                self.invalidate_plan_cache();
1998                self.invalidate_result_cache();
1999                // Issue #120 — surface policy names in the
2000                // schema-vocabulary so AskPipeline (#121) can resolve
2001                // a policy reference back to its table.
2002                self.schema_vocabulary_apply(
2003                    crate::runtime::schema_vocabulary::DdlEvent::CreatePolicy {
2004                        collection: q.table.clone(),
2005                        policy: q.name.clone(),
2006                    },
2007                );
2008                Ok(RuntimeQueryResult::ok_message(
2009                    query.to_string(),
2010                    &format!("policy {} on {} created", q.name, q.table),
2011                    "create_policy",
2012                ))
2013            }
2014            QueryExpr::DropPolicy(ref q) => {
2015                let removed = self
2016                    .inner
2017                    .rls_policies
2018                    .write()
2019                    .remove(&(q.table.clone(), q.name.clone()))
2020                    .is_some();
2021                if !removed && !q.if_exists {
2022                    return Err(RedDBError::Internal(format!(
2023                        "policy {} on {} does not exist",
2024                        q.name, q.table
2025                    )));
2026                }
2027                self.invalidate_plan_cache();
2028                self.invalidate_result_cache();
2029                // Issue #120 — keep the schema-vocabulary policy
2030                // entry in sync.
2031                self.schema_vocabulary_apply(
2032                    crate::runtime::schema_vocabulary::DdlEvent::DropPolicy {
2033                        collection: q.table.clone(),
2034                        policy: q.name.clone(),
2035                    },
2036                );
2037                Ok(RuntimeQueryResult::ok_message(
2038                    query.to_string(),
2039                    &format!("policy {} on {} dropped", q.name, q.table),
2040                    "drop_policy",
2041                ))
2042            }
2043            // Foreign Data Wrappers (Phase 3.2 PG parity).
2044            //
2045            // CREATE SERVER / CREATE FOREIGN TABLE register into the shared
2046            // `ForeignTableRegistry`. The read path consults that registry
2047            // before dispatching a SELECT — when the table name matches a
2048            // registered foreign table, we forward the scan to the wrapper
2049            // and skip the normal collection lookup.
2050            //
2051            // Phase 3.2 is in-memory only; persistence across restarts is a
2052            // 3.2.2 follow-up that mirrors the view registry pattern.
2053            QueryExpr::CreateServer(ref q) => {
2054                use crate::storage::fdw::FdwOptions;
2055                let registry = Arc::clone(&self.inner.foreign_tables);
2056                if registry.server(&q.name).is_some() {
2057                    if q.if_not_exists {
2058                        return Ok(RuntimeQueryResult::ok_message(
2059                            query.to_string(),
2060                            &format!("server {} already exists — skipped", q.name),
2061                            "create_server",
2062                        ));
2063                    }
2064                    return Err(RedDBError::Internal(format!(
2065                        "server {} already exists",
2066                        q.name
2067                    )));
2068                }
2069                let mut opts = FdwOptions::new();
2070                for (k, v) in &q.options {
2071                    opts.values.insert(k.clone(), v.clone());
2072                }
2073                registry
2074                    .create_server(&q.name, &q.wrapper, opts)
2075                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2076                Ok(RuntimeQueryResult::ok_message(
2077                    query.to_string(),
2078                    &format!("server {} created (wrapper {})", q.name, q.wrapper),
2079                    "create_server",
2080                ))
2081            }
2082            QueryExpr::DropServer(ref q) => {
2083                let existed = self.inner.foreign_tables.drop_server(&q.name);
2084                if !existed && !q.if_exists {
2085                    return Err(RedDBError::Internal(format!(
2086                        "server {} does not exist",
2087                        q.name
2088                    )));
2089                }
2090                Ok(RuntimeQueryResult::ok_message(
2091                    query.to_string(),
2092                    &format!(
2093                        "server {} dropped{}",
2094                        q.name,
2095                        if q.cascade { " (cascade)" } else { "" }
2096                    ),
2097                    "drop_server",
2098                ))
2099            }
2100            QueryExpr::CreateForeignTable(ref q) => {
2101                use crate::storage::fdw::{FdwOptions, ForeignColumn, ForeignTable};
2102                let registry = Arc::clone(&self.inner.foreign_tables);
2103                if registry.foreign_table(&q.name).is_some() {
2104                    if q.if_not_exists {
2105                        return Ok(RuntimeQueryResult::ok_message(
2106                            query.to_string(),
2107                            &format!("foreign table {} already exists — skipped", q.name),
2108                            "create_foreign_table",
2109                        ));
2110                    }
2111                    return Err(RedDBError::Internal(format!(
2112                        "foreign table {} already exists",
2113                        q.name
2114                    )));
2115                }
2116                let mut opts = FdwOptions::new();
2117                for (k, v) in &q.options {
2118                    opts.values.insert(k.clone(), v.clone());
2119                }
2120                let columns: Vec<ForeignColumn> = q
2121                    .columns
2122                    .iter()
2123                    .map(|c| ForeignColumn {
2124                        name: c.name.clone(),
2125                        data_type: c.data_type.clone(),
2126                        not_null: c.not_null,
2127                    })
2128                    .collect();
2129                registry
2130                    .create_foreign_table(ForeignTable {
2131                        name: q.name.clone(),
2132                        server_name: q.server.clone(),
2133                        columns,
2134                        options: opts,
2135                    })
2136                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2137                self.invalidate_plan_cache();
2138                Ok(RuntimeQueryResult::ok_message(
2139                    query.to_string(),
2140                    &format!("foreign table {} created (server {})", q.name, q.server),
2141                    "create_foreign_table",
2142                ))
2143            }
2144            QueryExpr::DropForeignTable(ref q) => {
2145                let existed = self.inner.foreign_tables.drop_foreign_table(&q.name);
2146                if !existed && !q.if_exists {
2147                    return Err(RedDBError::Internal(format!(
2148                        "foreign table {} does not exist",
2149                        q.name
2150                    )));
2151                }
2152                self.invalidate_plan_cache();
2153                Ok(RuntimeQueryResult::ok_message(
2154                    query.to_string(),
2155                    &format!("foreign table {} dropped", q.name),
2156                    "drop_foreign_table",
2157                ))
2158            }
2159            // COPY table FROM 'path' (Phase 1.5 PG parity).
2160            //
2161            // Stream CSV rows through the shared `CsvImporter`. The collection
2162            // is auto-created on first insert (via `insert_auto`-style path);
2163            // VACUUM/ANALYZE afterwards is up to the caller.
2164            QueryExpr::CopyFrom(ref q) => {
2165                use crate::storage::import::{CsvConfig, CsvImporter};
2166                self.check_copy_from_privilege(&q.table)?;
2167                confine_copy_from_path(&q.path)?;
2168                let store = self.inner.db.store();
2169                let cfg = CsvConfig {
2170                    collection: q.table.clone(),
2171                    has_header: q.has_header,
2172                    delimiter: q.delimiter.map(|c| c as u8).unwrap_or(b','),
2173                    ..CsvConfig::default()
2174                };
2175                let importer = CsvImporter::new(cfg);
2176                let stats = importer
2177                    .import_file(&q.path, store.as_ref())
2178                    .map_err(|e| RedDBError::Internal(format!("COPY failed: {e}")))?;
2179                // Tables are written → invalidate cached plans / result cache.
2180                self.note_table_write(&q.table);
2181                Ok(RuntimeQueryResult::ok_message(
2182                    query.to_string(),
2183                    &format!(
2184                        "COPY imported {} rows into {} ({} errors skipped, {}ms)",
2185                        stats.records_imported, q.table, stats.errors_skipped, stats.duration_ms
2186                    ),
2187                    "copy_from",
2188                ))
2189            }
2190            // Maintenance commands (Phase 1.2 PG parity).
2191            //
2192            // - VACUUM [FULL] [table]: refreshes planner stats for the target
2193            //   collection(s) and — when FULL — triggers a full pager persist
2194            //   (flushes dirty pages + fsync). Also invalidates the result cache
2195            //   so subsequent reads re-execute against the freshly compacted
2196            //   storage. RedDB's segment/btree GC runs continuously via the
2197            //   background lifecycle; explicit space reclamation for sealed
2198            //   segments arrives with Phase 2.3 (MVCC + dead-tuple reclamation).
2199            // - ANALYZE [table]: reruns `analyze_collection` +
2200            //   `persist_table_stats` via `refresh_table_planner_stats` so the
2201            //   planner has fresh histograms, distinct estimates, null counts.
2202            //
2203            // Both commands accept an optional target; omitting the target
2204            // iterates every collection in the store.
2205            QueryExpr::MaintenanceCommand(ref cmd) => {
2206                use reddb_rql::ast::MaintenanceCommand as Mc;
2207                let store = self.inner.db.store();
2208                let (kind, msg) = match cmd {
2209                    Mc::Analyze { target } => {
2210                        let targets: Vec<String> = match target {
2211                            Some(t) => vec![t.clone()],
2212                            None => store.list_collections(),
2213                        };
2214                        for t in &targets {
2215                            self.refresh_table_planner_stats(t);
2216                        }
2217                        (
2218                            "analyze",
2219                            format!("ANALYZE refreshed stats for {} table(s)", targets.len()),
2220                        )
2221                    }
2222                    Mc::Vacuum { target, full } => {
2223                        let targets: Vec<String> = match target {
2224                            Some(t) => vec![t.clone()],
2225                            None => store.list_collections(),
2226                        };
2227                        let cutoff_xid = self.mvcc_vacuum_cutoff_xid();
2228                        let mut vacuum_stats =
2229                            crate::storage::unified::store::MvccVacuumStats::default();
2230                        for t in &targets {
2231                            let stats = store.vacuum_mvcc_history(t, cutoff_xid).map_err(|e| {
2232                                RedDBError::Internal(format!(
2233                                    "VACUUM MVCC history failed for {t}: {e}"
2234                                ))
2235                            })?;
2236                            if stats.reclaimed_versions > 0 {
2237                                self.rebuild_runtime_indexes_for_table(t)?;
2238                            }
2239                            vacuum_stats.add(&stats);
2240                        }
2241                        self.inner
2242                            .transaction_state
2243                            .snapshot_manager()
2244                            .prune_aborted(cutoff_xid);
2245                        // Stats refresh covers every target (same as ANALYZE).
2246                        for t in &targets {
2247                            self.refresh_table_planner_stats(t);
2248                        }
2249                        // FULL forces a pager persist (dirty-page flush + fsync).
2250                        // Regular VACUUM relies on the background writer / segment
2251                        // lifecycle so the command is non-blocking.
2252                        let persisted = if *full {
2253                            match store.persist() {
2254                                Ok(()) => true,
2255                                Err(e) => {
2256                                    return Err(RedDBError::Internal(format!(
2257                                        "VACUUM FULL persist failed: {e:?}"
2258                                    )));
2259                                }
2260                            }
2261                        } else {
2262                            false
2263                        };
2264                        // Result cache depended on pre-vacuum state.
2265                        self.invalidate_result_cache();
2266                        (
2267                            "vacuum",
2268                            format!(
2269                                "VACUUM{} processed {} table(s): scanned_versions={}, retained_versions={}, reclaimed_versions={}, retained_history_versions={}, reclaimed_history_versions={}, retained_tombstones={}, reclaimed_tombstones={}{}",
2270                                if *full { " FULL" } else { "" },
2271                                targets.len(),
2272                                vacuum_stats.scanned_versions,
2273                                vacuum_stats.retained_versions,
2274                                vacuum_stats.reclaimed_versions,
2275                                vacuum_stats.retained_history_versions,
2276                                vacuum_stats.reclaimed_history_versions,
2277                                vacuum_stats.retained_tombstones,
2278                                vacuum_stats.reclaimed_tombstones,
2279                                if persisted {
2280                                    " (pages flushed to disk)"
2281                                } else {
2282                                    ""
2283                                }
2284                            ),
2285                        )
2286                    }
2287                };
2288                Ok(RuntimeQueryResult::ok_message(
2289                    query.to_string(),
2290                    &msg,
2291                    kind,
2292                ))
2293            }
2294            // GRANT / REVOKE / ALTER USER (RBAC milestone).
2295            //
2296            // These hit the AuthStore directly. The statement frame /
2297            // privilege gate has already decided whether the caller may
2298            // even run the statement; here we just translate the AST into
2299            // AuthStore calls.
2300            QueryExpr::Grant(ref g) => self.execute_grant_statement(query, g),
2301            QueryExpr::Revoke(ref r) => self.execute_revoke_statement(query, r),
2302            QueryExpr::AlterUser(ref a) => self.execute_alter_user_statement(query, a),
2303            QueryExpr::CreateUser(ref u) => self.execute_create_user_statement(query, u),
2304            QueryExpr::CreateIamPolicy { ref id, ref json } => {
2305                self.execute_create_iam_policy(query, id, json)
2306            }
2307            QueryExpr::DropIamPolicy { ref id } => self.execute_drop_iam_policy(query, id),
2308            QueryExpr::AttachPolicy {
2309                ref policy_id,
2310                ref principal,
2311            } => self.execute_attach_policy(query, policy_id, principal),
2312            QueryExpr::DetachPolicy {
2313                ref policy_id,
2314                ref principal,
2315            } => self.execute_detach_policy(query, policy_id, principal),
2316            QueryExpr::ShowPolicies { ref filter } => {
2317                self.execute_show_policies(query, filter.as_ref())
2318            }
2319            QueryExpr::ShowEffectivePermissions {
2320                ref user,
2321                ref resource,
2322            } => self.execute_show_effective_permissions(query, user, resource.as_ref()),
2323            QueryExpr::SimulatePolicy {
2324                ref user,
2325                ref action,
2326                ref resource,
2327            } => self.execute_simulate_policy(query, user, action, resource),
2328            QueryExpr::LintPolicy { ref source } => self.execute_lint_policy(query, source),
2329            QueryExpr::MigratePolicyMode {
2330                ref target,
2331                dry_run,
2332            } => self.execute_migrate_policy_mode(query, target, dry_run),
2333            QueryExpr::CreateMigration(ref q) => self.execute_create_migration(query, q),
2334            QueryExpr::ApplyMigration(ref q) => self.execute_apply_migration(query, q),
2335            QueryExpr::RollbackMigration(ref q) => self.execute_rollback_migration(query, q),
2336            QueryExpr::ExplainMigration(ref q) => self.execute_explain_migration(query, q),
2337            _ => Err(RedDBError::Query(
2338                "unsupported command in runtime dispatcher".to_string(),
2339            )),
2340        };
2341
2342        if !control_event_specs.is_empty() {
2343            let (outcome, reason) = match &query_result {
2344                Ok(_) => (crate::runtime::control_events::Outcome::Allowed, None),
2345                Err(err) => (control_event_outcome_for_error(err), Some(err.to_string())),
2346            };
2347            for spec in &control_event_specs {
2348                self.emit_control_event(
2349                    spec.kind,
2350                    outcome,
2351                    spec.action,
2352                    spec.resource.clone(),
2353                    reason.clone(),
2354                    spec.fields.clone(),
2355                )?;
2356            }
2357        }
2358
2359        if let (Some(plan), Ok(result)) = (&query_audit_plan, &query_result) {
2360            self.emit_query_audit(
2361                query,
2362                plan,
2363                query_audit_started.elapsed().as_millis() as u64,
2364                result,
2365            );
2366        }
2367
2368        // Cache SELECT results for 30s.
2369        // Skip: pre-serialized JSON (large clone), and result sets > 5 rows.
2370        // Large multi-row results (range scans, filtered scans) are rarely
2371        // repeated with the same literal values so the cache hit rate is near
2372        // zero while the clone cost (100 records × ~16 fields each) is high.
2373        // Aggregations (1 row) and point lookups (1 row) still benefit.
2374        if let Ok(ref result) = query_result {
2375            frame.write_result_cache(self, result, result_cache_scopes);
2376        }
2377
2378        query_result
2379    }
2380
2381    /// Execute a pre-parsed `QueryExpr` that has no accompanying SQL text,
2382    /// bypassing SQL parsing and the plan cache.
2383    ///
2384    /// Prefer [`Self::execute_prepared_query`] or
2385    /// [`Self::execute_query_with_params`]: they carry the statement text, so
2386    /// the frame's snapshot (including `AS OF`), coarse privilege class, lock
2387    /// intent, and result-cache key all describe the statement that actually
2388    /// runs. This entry exists for the nested in-statement callers — the CTE
2389    /// prelude, materialized-view refresh, and the inline graph TVF — which
2390    /// hold an expression the outer statement already parsed.
2391    ///
2392    /// Cost: this is no longer a "zero overhead" path. It builds and installs
2393    /// a full `StatementExecutionFrame` (tenant / config / secret / KV guards
2394    /// plus an MVCC snapshot), which is what buys it the same snapshot
2395    /// isolation as textual SQL. Nested calls inherit the enclosing
2396    /// statement's snapshot rather than minting a new one, and the frame opts
2397    /// out of the result cache because there is no stable text key.
2398    ///
2399    /// Keeps secret SELECT results masked, identical to `execute_query`.
2400    pub fn execute_query_expr(&self, expr: QueryExpr) -> RedDBResult<RuntimeQueryResult> {
2401        let frame = super::statement_frame::StatementExecutionFrame::build(
2402            self,
2403            super::statement_frame::StatementIdentity::Expr(&expr),
2404        )?;
2405        let _frame_guards = Box::new(frame.install(self));
2406        // View rewrite (Phase 2.1): substitute any `QueryExpr::Table(tq)`
2407        // whose `tq.table` matches a registered view with the view's
2408        // underlying query. Safe to call even when no views are registered.
2409        let expr = self.rewrite_view_refs(expr);
2410
2411        self.validate_model_operations_before_auth(&expr)?;
2412        // Granular RBAC privilege check. Runs before dispatch so a
2413        // denied caller never reaches storage. Fail-closed: any error
2414        // resolving the action / resource produces PermissionDenied.
2415        if let Err(err) = self.check_query_privilege(&expr) {
2416            return Err(RedDBError::Query(format!("permission denied: {err}")));
2417        }
2418
2419        let statement = query_expr_name(&expr);
2420        let mode = detect_mode(statement);
2421        let query_str = statement;
2422
2423        self.dispatch_expr(expr, query_str, mode)
2424    }
2425
2426    pub(super) fn validate_model_operations_before_auth(
2427        &self,
2428        expr: &QueryExpr,
2429    ) -> RedDBResult<()> {
2430        self.validate_vault_table_access(expr)?;
2431        use crate::catalog::CollectionModel;
2432        use crate::runtime::ddl::polymorphic_resolver;
2433        use reddb_rql::ast::KvCommand;
2434
2435        let system_schema_target = match expr {
2436            QueryExpr::DropTable(q) => Some(q.name.as_str()),
2437            QueryExpr::DropGraph(q) => Some(q.name.as_str()),
2438            QueryExpr::DropVector(q) => Some(q.name.as_str()),
2439            QueryExpr::DropDocument(q) => Some(q.name.as_str()),
2440            QueryExpr::DropKv(q) => Some(q.name.as_str()),
2441            QueryExpr::DropCollection(q) => Some(q.name.as_str()),
2442            QueryExpr::Truncate(q) => Some(q.name.as_str()),
2443            _ => None,
2444        };
2445        if system_schema_target.is_some_and(crate::runtime::impl_ddl::is_system_schema_name) {
2446            return Err(RedDBError::Query("system schema is read-only".to_string()));
2447        }
2448
2449        let expected = match expr {
2450            QueryExpr::DropTable(q) => Some((q.name.as_str(), CollectionModel::Table)),
2451            QueryExpr::DropGraph(q) => Some((q.name.as_str(), CollectionModel::Graph)),
2452            QueryExpr::DropVector(q) => Some((q.name.as_str(), CollectionModel::Vector)),
2453            QueryExpr::DropDocument(q) => Some((q.name.as_str(), CollectionModel::Document)),
2454            QueryExpr::DropKv(q) => Some((q.name.as_str(), q.model)),
2455            QueryExpr::DropCollection(q) => q.model.map(|model| (q.name.as_str(), model)),
2456            QueryExpr::Truncate(q) => q.model.map(|model| (q.name.as_str(), model)),
2457            QueryExpr::KvCommand(cmd) => {
2458                let (collection, model) = match cmd {
2459                    KvCommand::Put {
2460                        collection, model, ..
2461                    }
2462                    | KvCommand::Get {
2463                        collection, model, ..
2464                    }
2465                    | KvCommand::Incr {
2466                        collection, model, ..
2467                    }
2468                    | KvCommand::Cas {
2469                        collection, model, ..
2470                    }
2471                    | KvCommand::List {
2472                        collection, model, ..
2473                    }
2474                    | KvCommand::Delete {
2475                        collection, model, ..
2476                    } => (collection.as_str(), *model),
2477                    KvCommand::Rotate { collection, .. }
2478                    | KvCommand::History { collection, .. }
2479                    | KvCommand::Purge { collection, .. } => {
2480                        (collection.as_str(), CollectionModel::Vault)
2481                    }
2482                    KvCommand::InvalidateTags { collection, .. } => {
2483                        (collection.as_str(), CollectionModel::Kv)
2484                    }
2485                    KvCommand::Watch {
2486                        collection, model, ..
2487                    } => (collection.as_str(), *model),
2488                    KvCommand::Reveal { collection, .. } => {
2489                        (collection.as_str(), CollectionModel::Vault)
2490                    }
2491                };
2492                Some((collection, model))
2493            }
2494            QueryExpr::ConfigCommand(cmd) => {
2495                self.validate_config_command_before_auth(cmd)?;
2496                None
2497            }
2498            _ => None,
2499        };
2500
2501        let Some((name, expected_model)) = expected else {
2502            return Ok(());
2503        };
2504        let snapshot = self.inner.db.catalog_model_snapshot();
2505        let Some(actual_model) = snapshot
2506            .collections
2507            .iter()
2508            .find(|collection| collection.name == name)
2509            .map(|collection| collection.declared_model.unwrap_or(collection.model))
2510        else {
2511            return Ok(());
2512        };
2513        polymorphic_resolver::ensure_model_match(expected_model, actual_model)
2514    }
2515
2516    /// Walk a `QueryExpr` and replace `QueryExpr::Table(tq)` nodes whose
2517    /// `tq.table` matches a registered view name with the view's stored
2518    /// body. Recurses through joins so `SELECT ... FROM t JOIN myview ...`
2519    /// resolves correctly. Pure operation — no side effects.
2520    pub(super) fn rewrite_view_refs(&self, expr: QueryExpr) -> QueryExpr {
2521        // Fast path: no views registered → return original expression.
2522        if self.inner.views.read().is_empty() {
2523            return expr;
2524        }
2525        self.rewrite_view_refs_inner(expr)
2526    }
2527
2528    fn rewrite_view_refs_inner(&self, expr: QueryExpr) -> QueryExpr {
2529        use reddb_rql::ast::{Filter, TableSource};
2530        match expr {
2531            QueryExpr::Table(mut tq) => {
2532                // 1. If the TableSource is a subquery, recurse into it so
2533                //    `SELECT ... FROM (SELECT ... FROM myview) t` expands.
2534                //    The legacy `table` field (set to a synthetic
2535                //    "__subq_NNNN" sentinel) stays as-is so callers that
2536                //    read it keep compiling.
2537                if let Some(TableSource::Subquery(body)) = tq.source.take() {
2538                    tq.source = Some(TableSource::Subquery(Box::new(
2539                        self.rewrite_view_refs_inner(*body),
2540                    )));
2541                    return QueryExpr::Table(tq);
2542                }
2543
2544                // 2. Restore the source field (took it above for match).
2545                // When the source was `None` or `TableSource::Name(_)`, the
2546                // real lookup key is `tq.table` — check the view registry.
2547                let maybe_view = {
2548                    let views = self.inner.views.read();
2549                    views.get(&tq.table).cloned()
2550                };
2551                let Some(view) = maybe_view else {
2552                    return QueryExpr::Table(tq);
2553                };
2554
2555                // Issue #594 slice 9b — materialized views are read
2556                // from their backing collection, not by substituting
2557                // the body. Returning the TableQuery as-is lets the
2558                // normal table-read path resolve `SELECT FROM v`
2559                // against the collection provisioned at CREATE time.
2560                if view.materialized {
2561                    return QueryExpr::Table(tq);
2562                }
2563
2564                // Recurse into the view body — views may reference other
2565                // views. The recursion yields the final QueryExpr we need
2566                // to merge the outer's filter / limit / offset into.
2567                let inner_expr = self.rewrite_view_refs_inner((*view.query).clone());
2568
2569                // Phase 5: when the body is a Table we merge the outer
2570                // TableQuery's WHERE / LIMIT / OFFSET into it so stacked
2571                // views filter recursively. Non-table bodies (Search,
2572                // Ask, Vector, Graph, Hybrid) can't meaningfully combine
2573                // with an outer Table query today — return the body
2574                // verbatim; outer predicates are lost. Full projection
2575                // merge lands in Phase 5.2.
2576                match inner_expr {
2577                    QueryExpr::Table(mut inner_tq) => {
2578                        if let Some(outer_filter) = tq.filter.take() {
2579                            inner_tq.filter = Some(match inner_tq.filter.take() {
2580                                Some(existing) => {
2581                                    Filter::And(Box::new(existing), Box::new(outer_filter))
2582                                }
2583                                None => outer_filter,
2584                            });
2585                            // Keep the `Expr` form in lock-step with the
2586                            // merged `Filter`. The executor prefers
2587                            // `where_expr` and nulls `filter` when it is
2588                            // present (see `execute_query_inner`), so a
2589                            // stacked view whose outer predicate was only
2590                            // merged into `filter` would silently drop that
2591                            // predicate at eval time (#635).
2592                            inner_tq.where_expr = inner_tq
2593                                .filter
2594                                .as_ref()
2595                                .map(reddb_rql::sql_lowering::filter_to_expr);
2596                        }
2597                        if let Some(outer_limit) = tq.limit {
2598                            inner_tq.limit = Some(match inner_tq.limit {
2599                                Some(existing) => existing.min(outer_limit),
2600                                None => outer_limit,
2601                            });
2602                        }
2603                        if let Some(outer_offset) = tq.offset {
2604                            inner_tq.offset = Some(match inner_tq.offset {
2605                                Some(existing) => existing + outer_offset,
2606                                None => outer_offset,
2607                            });
2608                        }
2609                        QueryExpr::Table(inner_tq)
2610                    }
2611                    other => other,
2612                }
2613            }
2614            QueryExpr::Join(mut jq) => {
2615                jq.left = Box::new(self.rewrite_view_refs_inner(*jq.left));
2616                jq.right = Box::new(self.rewrite_view_refs_inner(*jq.right));
2617                QueryExpr::Join(jq)
2618            }
2619            // Other variants don't carry nested QueryExpr that can reference
2620            // a view by table name. Return as-is.
2621            other => other,
2622        }
2623    }
2624
2625    fn resolve_table_expr_subqueries(
2626        &self,
2627        mut table: TableQuery,
2628        frame: &dyn super::statement_frame::ReadFrame,
2629    ) -> RedDBResult<TableQuery> {
2630        // Only a `Subquery` source needs recursive resolution. `.take()`
2631        // would otherwise drop a `Name` / `Function` source on the floor
2632        // (the `if let` skips the body but the take already cleared it),
2633        // which silently broke `SELECT * FROM components(g)` — the TVF
2634        // dispatch downstream keys off `TableSource::Function` and never
2635        // fired. Restore any non-subquery source unchanged (issue #795).
2636        match table.source.take() {
2637            Some(TableSource::Subquery(inner)) => {
2638                let inner = self.resolve_select_expr_subqueries(*inner, frame)?;
2639                table.source = Some(TableSource::Subquery(Box::new(inner)));
2640            }
2641            other => table.source = other,
2642        }
2643
2644        let outer_scopes = relation_scopes_for_query(&QueryExpr::Table(table.clone()));
2645        for item in &mut table.select_items {
2646            if let reddb_rql::ast::SelectItem::Expr { expr, .. } = item {
2647                *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2648            }
2649        }
2650        if let Some(where_expr) = table.where_expr.take() {
2651            table.where_expr =
2652                Some(self.resolve_expr_subqueries(where_expr, &outer_scopes, frame)?);
2653            table.filter = None;
2654        }
2655        if let Some(having_expr) = table.having_expr.take() {
2656            table.having_expr =
2657                Some(self.resolve_expr_subqueries(having_expr, &outer_scopes, frame)?);
2658            table.having = None;
2659        }
2660        for expr in &mut table.group_by_exprs {
2661            *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2662        }
2663        for clause in &mut table.order_by {
2664            if let Some(expr) = clause.expr.take() {
2665                clause.expr = Some(self.resolve_expr_subqueries(expr, &outer_scopes, frame)?);
2666            }
2667        }
2668        Ok(table)
2669    }
2670
2671    fn resolve_select_expr_subqueries(
2672        &self,
2673        expr: QueryExpr,
2674        frame: &dyn super::statement_frame::ReadFrame,
2675    ) -> RedDBResult<QueryExpr> {
2676        match expr {
2677            QueryExpr::Table(table) => self
2678                .resolve_table_expr_subqueries(table, frame)
2679                .map(QueryExpr::Table),
2680            QueryExpr::Join(mut join) => {
2681                join.left = Box::new(self.resolve_select_expr_subqueries(*join.left, frame)?);
2682                join.right = Box::new(self.resolve_select_expr_subqueries(*join.right, frame)?);
2683                Ok(QueryExpr::Join(join))
2684            }
2685            other => Ok(other),
2686        }
2687    }
2688
2689    fn resolve_expr_subqueries(
2690        &self,
2691        expr: reddb_rql::ast::Expr,
2692        outer_scopes: &[String],
2693        frame: &dyn super::statement_frame::ReadFrame,
2694    ) -> RedDBResult<reddb_rql::ast::Expr> {
2695        use reddb_rql::ast::Expr;
2696
2697        match expr {
2698            Expr::Subquery { query, span } => {
2699                let values = self.execute_expr_subquery_values(query, outer_scopes, frame)?;
2700                if values.len() > 1 {
2701                    return Err(RedDBError::Query(
2702                        "scalar subquery returned more than one row".to_string(),
2703                    ));
2704                }
2705                Ok(Expr::Literal {
2706                    value: values.into_iter().next().unwrap_or(Value::Null),
2707                    span,
2708                })
2709            }
2710            Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
2711                op,
2712                lhs: Box::new(self.resolve_expr_subqueries(*lhs, outer_scopes, frame)?),
2713                rhs: Box::new(self.resolve_expr_subqueries(*rhs, outer_scopes, frame)?),
2714                span,
2715            }),
2716            Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
2717                op,
2718                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2719                span,
2720            }),
2721            Expr::Cast {
2722                inner,
2723                target,
2724                span,
2725            } => Ok(Expr::Cast {
2726                inner: Box::new(self.resolve_expr_subqueries(*inner, outer_scopes, frame)?),
2727                target,
2728                span,
2729            }),
2730            Expr::FunctionCall { name, args, span } => {
2731                let args = args
2732                    .into_iter()
2733                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2734                    .collect::<RedDBResult<Vec<_>>>()?;
2735                Ok(Expr::FunctionCall { name, args, span })
2736            }
2737            Expr::WindowFunctionCall {
2738                name,
2739                args,
2740                mut window,
2741                span,
2742            } => {
2743                let args = args
2744                    .into_iter()
2745                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2746                    .collect::<RedDBResult<Vec<_>>>()?;
2747                for expr in &mut window.partition_by {
2748                    *expr = self.resolve_expr_subqueries(expr.clone(), outer_scopes, frame)?;
2749                }
2750                for clause in &mut window.order_by {
2751                    clause.expr =
2752                        self.resolve_expr_subqueries(clause.expr.clone(), outer_scopes, frame)?;
2753                }
2754                Ok(Expr::WindowFunctionCall {
2755                    name,
2756                    args,
2757                    window,
2758                    span,
2759                })
2760            }
2761            Expr::Case {
2762                branches,
2763                else_,
2764                span,
2765            } => {
2766                let branches = branches
2767                    .into_iter()
2768                    .map(|(cond, value)| {
2769                        Ok((
2770                            self.resolve_expr_subqueries(cond, outer_scopes, frame)?,
2771                            self.resolve_expr_subqueries(value, outer_scopes, frame)?,
2772                        ))
2773                    })
2774                    .collect::<RedDBResult<Vec<_>>>()?;
2775                let else_ = else_
2776                    .map(|expr| self.resolve_expr_subqueries(*expr, outer_scopes, frame))
2777                    .transpose()?
2778                    .map(Box::new);
2779                Ok(Expr::Case {
2780                    branches,
2781                    else_,
2782                    span,
2783                })
2784            }
2785            Expr::IsNull {
2786                operand,
2787                negated,
2788                span,
2789            } => Ok(Expr::IsNull {
2790                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2791                negated,
2792                span,
2793            }),
2794            Expr::InList {
2795                target,
2796                values,
2797                negated,
2798                span,
2799            } => {
2800                let target =
2801                    Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?);
2802                let mut resolved = Vec::new();
2803                for value in values {
2804                    if let Expr::Subquery { query, .. } = value {
2805                        resolved.extend(
2806                            self.execute_expr_subquery_values(query, outer_scopes, frame)?
2807                                .into_iter()
2808                                .map(Expr::lit),
2809                        );
2810                    } else {
2811                        resolved.push(self.resolve_expr_subqueries(value, outer_scopes, frame)?);
2812                    }
2813                }
2814                Ok(Expr::InList {
2815                    target,
2816                    values: resolved,
2817                    negated,
2818                    span,
2819                })
2820            }
2821            Expr::Between {
2822                target,
2823                low,
2824                high,
2825                negated,
2826                span,
2827            } => Ok(Expr::Between {
2828                target: Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?),
2829                low: Box::new(self.resolve_expr_subqueries(*low, outer_scopes, frame)?),
2830                high: Box::new(self.resolve_expr_subqueries(*high, outer_scopes, frame)?),
2831                negated,
2832                span,
2833            }),
2834            other => Ok(other),
2835        }
2836    }
2837
2838    fn execute_expr_subquery_values(
2839        &self,
2840        subquery: reddb_rql::ast::ExprSubquery,
2841        outer_scopes: &[String],
2842        frame: &dyn super::statement_frame::ReadFrame,
2843    ) -> RedDBResult<Vec<Value>> {
2844        let query = *subquery.query;
2845        if query_references_outer_scope(&query, outer_scopes) {
2846            return Err(RedDBError::Query(
2847                "NOT_YET_SUPPORTED: correlated subqueries are not supported yet; track follow-up issue #470-correlated-subqueries".to_string(),
2848            ));
2849        }
2850        let query = self.rewrite_view_refs(query);
2851        let query = self.resolve_select_expr_subqueries(query, frame)?;
2852        let query = self.authorize_relational_select_expr(query, frame)?;
2853        let result = match query {
2854            QueryExpr::Table(table) => {
2855                execute_runtime_table_query(&self.inner.db, &table, Some(&self.inner.index_store))?
2856            }
2857            QueryExpr::Join(join) => execute_runtime_join_query(self, &join)?,
2858            other => {
2859                return Err(RedDBError::Query(format!(
2860                    "expression subquery must be a SELECT query, got {}",
2861                    query_expr_name(&other)
2862                )))
2863            }
2864        };
2865        first_column_values(result)
2866    }
2867
2868    fn execute_materialized_graph(
2869        &self,
2870        query: &str,
2871        mode: QueryMode,
2872        statement: &'static str,
2873        expr: &QueryExpr,
2874    ) -> RedDBResult<RuntimeQueryResult> {
2875        // Apply MVCC visibility + RLS gate while materialising the
2876        // graph: every node entity is screened against the source
2877        // collection's policy chain (basic and `Nodes`-targeted)
2878        // and dropped when the caller's tenant / role doesn't
2879        // admit it. Edges are pruned automatically because the
2880        // graph builder skips edges whose endpoints aren't in
2881        // `allowed_nodes`.
2882        let (graph, node_properties, edge_properties) = self.materialize_graph_with_rls()?;
2883        let result =
2884            crate::storage::query::unified::UnifiedExecutor::execute_on_with_graph_properties_checked(
2885                &graph,
2886                expr,
2887                node_properties,
2888                edge_properties,
2889                super::function_budget::graph_work_check(),
2890            )
2891            .map_err(|err| RedDBError::Query(err.to_string()))?;
2892
2893        Ok(RuntimeQueryResult {
2894            query: query.to_string(),
2895            mode,
2896            statement,
2897            engine: "materialized-graph",
2898            result,
2899            affected_rows: 0,
2900            statement_type: "select",
2901            bookmark: None,
2902            notice: None,
2903        })
2904    }
2905
2906    fn dispatch_expr(
2907        &self,
2908        expr: QueryExpr,
2909        query_str: &str,
2910        mode: QueryMode,
2911    ) -> RedDBResult<RuntimeQueryResult> {
2912        let statement = query_expr_name(&expr);
2913        match expr {
2914            QueryExpr::Function(command) => self.execute_function(query_str, &command),
2915            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
2916                self.execute_materialized_graph(query_str, mode, statement, &expr)
2917            }
2918            QueryExpr::Table(table) => {
2919                let scope = self.ai_scope();
2920                let table = self.resolve_table_expr_subqueries(
2921                    table,
2922                    &scope as &dyn super::statement_frame::ReadFrame,
2923                )?;
2924                // Table-valued functions (e.g. components(g)) dispatch to a
2925                // read-only executor before any catalog/virtual-table routing
2926                // (issue #795).
2927                if let Some(TableSource::Function {
2928                    name,
2929                    args,
2930                    named_args,
2931                }) = table.source.clone()
2932                {
2933                    return Ok(RuntimeQueryResult {
2934                        query: query_str.to_string(),
2935                        mode,
2936                        statement,
2937                        engine: "runtime-graph-tvf",
2938                        result: self.execute_table_function(&name, &args, &named_args)?,
2939                        affected_rows: 0,
2940                        statement_type: "select",
2941                        bookmark: None,
2942                        notice: None,
2943                    });
2944                }
2945                // Inline-graph TVF (issue #799) on the prepared-statement /
2946                // direct-expr path. Result caching is wired on the
2947                // `execute_query_inner` path; here we just compute and return.
2948                if let Some(TableSource::InlineGraphFunction {
2949                    name,
2950                    nodes,
2951                    edges,
2952                    named_args,
2953                }) = table.source.clone()
2954                {
2955                    return Ok(RuntimeQueryResult {
2956                        query: query_str.to_string(),
2957                        mode,
2958                        statement,
2959                        engine: "runtime-graph-tvf-inline",
2960                        result: self.execute_inline_graph_function(
2961                            &name,
2962                            &nodes,
2963                            &edges,
2964                            &named_args,
2965                        )?,
2966                        affected_rows: 0,
2967                        statement_type: "select",
2968                        bookmark: None,
2969                        notice: None,
2970                    });
2971                }
2972                if super::red_schema::is_virtual_table(&table.table) {
2973                    return Ok(RuntimeQueryResult {
2974                        query: query_str.to_string(),
2975                        mode,
2976                        statement,
2977                        engine: "runtime-red-schema",
2978                        result: super::red_schema::red_query(
2979                            self,
2980                            &table.table,
2981                            &table,
2982                            &scope as &dyn super::statement_frame::ReadFrame,
2983                        )?,
2984                        affected_rows: 0,
2985                        statement_type: "select",
2986                        bookmark: None,
2987                        notice: None,
2988                    });
2989                }
2990                // `<graph>.<output>` analytics virtual view (issue #800).
2991                if let Some(view_result) = self.try_resolve_analytics_view(
2992                    &table,
2993                    &scope as &dyn super::statement_frame::ReadFrame,
2994                )? {
2995                    return Ok(RuntimeQueryResult {
2996                        query: query_str.to_string(),
2997                        mode,
2998                        statement,
2999                        engine: "runtime-graph-analytics-view",
3000                        result: view_result,
3001                        affected_rows: 0,
3002                        statement_type: "select",
3003                        bookmark: None,
3004                        notice: None,
3005                    });
3006                }
3007                let Some(table_with_rls) = self.authorize_relational_table_select(
3008                    table,
3009                    &scope as &dyn super::statement_frame::ReadFrame,
3010                )?
3011                else {
3012                    return Ok(RuntimeQueryResult {
3013                        query: query_str.to_string(),
3014                        mode,
3015                        statement,
3016                        engine: "runtime-table-rls",
3017                        result: crate::storage::query::unified::UnifiedResult::empty(),
3018                        affected_rows: 0,
3019                        statement_type: "select",
3020                        bookmark: None,
3021                        notice: None,
3022                    });
3023                };
3024                Ok(RuntimeQueryResult {
3025                    query: query_str.to_string(),
3026                    mode,
3027                    statement,
3028                    engine: "runtime-table",
3029                    result: execute_runtime_table_query(
3030                        &self.inner.db,
3031                        &table_with_rls,
3032                        Some(&self.inner.index_store),
3033                    )?,
3034                    affected_rows: 0,
3035                    statement_type: "select",
3036                    bookmark: None,
3037                    notice: None,
3038                })
3039            }
3040            QueryExpr::Join(join) => {
3041                let scope = self.ai_scope();
3042                let Some(join_with_rls) = self.authorize_relational_join_select(
3043                    join,
3044                    &scope as &dyn super::statement_frame::ReadFrame,
3045                )?
3046                else {
3047                    return Ok(RuntimeQueryResult {
3048                        query: query_str.to_string(),
3049                        mode,
3050                        statement,
3051                        engine: "runtime-join-rls",
3052                        result: crate::storage::query::unified::UnifiedResult::empty(),
3053                        affected_rows: 0,
3054                        statement_type: "select",
3055                        bookmark: None,
3056                        notice: None,
3057                    });
3058                };
3059                Ok(RuntimeQueryResult {
3060                    query: query_str.to_string(),
3061                    mode,
3062                    statement,
3063                    engine: "runtime-join",
3064                    result: execute_runtime_join_query(self, &join_with_rls)?,
3065                    affected_rows: 0,
3066                    statement_type: "select",
3067                    bookmark: None,
3068                    notice: None,
3069                })
3070            }
3071            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
3072                query: query_str.to_string(),
3073                mode,
3074                statement,
3075                engine: "runtime-vector",
3076                result: execute_runtime_vector_query(self, &vector)?,
3077                affected_rows: 0,
3078                statement_type: "select",
3079                bookmark: None,
3080                notice: None,
3081            }),
3082            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
3083                query: query_str.to_string(),
3084                mode,
3085                statement,
3086                engine: "runtime-hybrid",
3087                result: execute_runtime_hybrid_query(self, &hybrid)?,
3088                affected_rows: 0,
3089                statement_type: "select",
3090                bookmark: None,
3091                notice: None,
3092            }),
3093            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
3094                Err(RedDBError::Query(
3095                    super::red_schema::READ_ONLY_ERROR.to_string(),
3096                ))
3097            }
3098            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
3099                Err(RedDBError::Query(
3100                    super::red_schema::READ_ONLY_ERROR.to_string(),
3101                ))
3102            }
3103            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
3104                Err(RedDBError::Query(
3105                    super::red_schema::READ_ONLY_ERROR.to_string(),
3106                ))
3107            }
3108            QueryExpr::Insert(ref insert) => self
3109                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
3110                    self.execute_insert(query_str, insert)
3111                }),
3112            QueryExpr::Update(ref update) => self
3113                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
3114                    self.execute_update(query_str, update)
3115                }),
3116            QueryExpr::Delete(ref delete) => self
3117                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
3118                    self.execute_delete(query_str, delete)
3119                }),
3120            QueryExpr::QueueCommand(ref cmd) => self.with_deferred_store_wal_if_transaction(|| {
3121                self.execute_queue_command(query_str, cmd)
3122            }),
3123            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query_str, cmd),
3124            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query_str, cmd),
3125            QueryExpr::Ask(ref ask) => self.execute_ask(query_str, ask),
3126            _ => Err(RedDBError::Query(format!(
3127                "prepared-statement execution does not support {statement} statements"
3128            ))),
3129        }
3130    }
3131
3132    /// Dispatch a graph-collection table-valued function call in FROM
3133    /// position (e.g. `SELECT * FROM components(g)`).
3134    ///
3135    /// Validates the function name and arity here, materializes the whole
3136    /// active graph read-only, then runs the algorithm via the shared
3137    /// `dispatch_graph_algorithm` path. Never mutates the catalog or store.
3138    fn execute_table_function(
3139        &self,
3140        name: &str,
3141        args: &[String],
3142        named_args: &[(String, f64)],
3143    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
3144        if name.eq_ignore_ascii_case("red.diff") {
3145            return self.execute_vcs_diff_tvf(args, named_args);
3146        }
3147        if !is_graph_tvf_name(name) {
3148            return Err(RedDBError::Query(format!("unknown table function: {name}")));
3149        }
3150        // Every graph-collection TVF takes exactly one graph argument.
3151        if args.len() != 1 {
3152            return Err(RedDBError::Query(format!(
3153                "table function '{name}' takes exactly 1 graph argument, got {}",
3154                args.len()
3155            )));
3156        }
3157
3158        // Read-only materialization of the full active graph. Passing `None`
3159        // for the projection uses the full graph store. Like #795/#796, the
3160        // v0 form runs over the whole graph store regardless of the collection
3161        // argument value. Materialization never mutates any store.
3162        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
3163        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
3164    }
3165
3166    pub(crate) fn revive_versioned_updates_since(
3167        &self,
3168        conn_id: u64,
3169        stamper_xid: u64,
3170    ) -> RedDBResult<usize> {
3171        // Snapshot undo identities without retaining the journal while waiting
3172        // for topology. Writers take topology before recording their undo entry.
3173        let candidates = {
3174            let guard = self.inner.pending_versioned_updates.read();
3175            let Some(pending) = guard.get(&conn_id) else {
3176                return Ok(0);
3177            };
3178            pending
3179                .iter()
3180                .rev()
3181                .filter(|entry| entry.3 >= stamper_xid)
3182                .cloned()
3183                .collect::<Vec<_>>()
3184        };
3185        let mut reverted = 0usize;
3186        for candidate in candidates {
3187            let (collection, old_id, new_id, xid, previous_xmax) = &candidate;
3188            let topology_lock = self.index_store_ref().collection_topology_lock(collection);
3189            let topology_guard = topology_lock.read();
3190            let mut guard = self.inner.pending_versioned_updates.write();
3191            let Some(pending) = guard.get_mut(&conn_id) else {
3192                continue;
3193            };
3194            // Revalidate after waiting. Remove only successfully restored entries;
3195            // an error leaves this entry and the remaining undo journal intact.
3196            let Some(position) = pending.iter().rposition(|entry| entry == &candidate) else {
3197                continue;
3198            };
3199            self.revive_versioned_update(
3200                collection,
3201                *old_id,
3202                *new_id,
3203                *xid,
3204                *previous_xmax,
3205                topology_guard,
3206            )?;
3207            pending.remove(position);
3208            reverted += 1;
3209            if pending.is_empty() {
3210                guard.remove(&conn_id);
3211            }
3212        }
3213        Ok(reverted)
3214    }
3215
3216    /// `EXPLAIN` is peeled off the statement text before parsing, so the
3217    /// planner ran on the inner statement with no privilege check at all:
3218    /// a read-only principal could plan any write and read the table's
3219    /// column list and row estimate back out of the plan. The plan needs
3220    /// exactly the privilege the statement it describes needs.
3221    fn check_explain_target_privilege(&self, inner_sql: &str) -> RedDBResult<()> {
3222        let parsed =
3223            reddb_rql::parser::parse(inner_sql).map_err(|e| RedDBError::Query(e.to_string()))?;
3224        self.check_query_privilege(&parsed.query)
3225            .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))
3226    }
3227
3228    /// Wrap the planner's `RuntimeQueryExplain` as rows on a
3229    /// `RuntimeQueryResult` so callers over the SQL interface see the
3230    /// plan tree in the same shape a SELECT produces.
3231    ///
3232    /// Columns: `op`, `source`, `estimated_rows`, `estimated_cost`, `depth`.
3233    /// Nodes are walked depth-first; `depth` counts from 0 at the
3234    /// root so a text renderer can indent without re-walking.
3235    fn explain_as_rows(&self, raw_query: &str, inner_sql: &str) -> RedDBResult<RuntimeQueryResult> {
3236        self.check_explain_target_privilege(inner_sql)?;
3237        let explain = self.explain_query(inner_sql)?;
3238
3239        let mut columns = vec![
3240            "op".to_string(),
3241            "source".to_string(),
3242            "estimated_rows".to_string(),
3243            "estimated_cost".to_string(),
3244            "depth".to_string(),
3245        ];
3246
3247        let mut records: Vec<crate::storage::query::unified::UnifiedRecord> = Vec::new();
3248
3249        // Prepend `CteScan` markers when the query carried a leading
3250        // WITH clause. The CTE bodies are already inlined into the
3251        // main plan tree, but operators reading EXPLAIN need to see
3252        // which named CTEs were resolved — without this row the plan
3253        // would look indistinguishable from a hand-inlined query.
3254        for name in &explain.cte_materializations {
3255            use std::sync::Arc;
3256            let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3257            rec.set_arc(Arc::from("op"), Value::text("CteScan".to_string()));
3258            rec.set_arc(Arc::from("source"), Value::text(name.clone()));
3259            rec.set_arc(Arc::from("estimated_rows"), Value::Float(0.0));
3260            rec.set_arc(Arc::from("estimated_cost"), Value::Float(0.0));
3261            rec.set_arc(Arc::from("depth"), Value::Integer(0));
3262            records.push(rec);
3263        }
3264
3265        let vector_details = explain.statement == "vector";
3266        if vector_details {
3267            columns.extend(["access_path_reason".to_string(), "index_hint".to_string()]);
3268        }
3269        walk_plan_node(&explain.logical_plan.root, 0, &mut records, vector_details);
3270
3271        let result = crate::storage::query::unified::UnifiedResult {
3272            columns,
3273            records,
3274            stats: Default::default(),
3275            pre_serialized_json: None,
3276        };
3277
3278        Ok(RuntimeQueryResult {
3279            query: raw_query.to_string(),
3280            mode: explain.mode,
3281            statement: "explain",
3282            engine: "runtime-explain",
3283            result,
3284            affected_rows: 0,
3285            statement_type: "select",
3286            bookmark: None,
3287            notice: None,
3288        })
3289    }
3290
3291    fn explain_analyze_vector_as_rows(
3292        &self,
3293        raw_query: &str,
3294        expr: QueryExpr,
3295    ) -> RedDBResult<RuntimeQueryResult> {
3296        let QueryExpr::Vector(query) = &expr else {
3297            return Err(RedDBError::Internal(
3298                "expected vector analysis target".to_string(),
3299            ));
3300        };
3301        let source = query.collection.clone();
3302        // The typed entry installs the full statement frame and privilege gate,
3303        // and cannot serve an earlier result-cache measurement as a fresh run.
3304        let execution = self.execute_query_expr(expr)?;
3305        let stats = execution.result.stats;
3306        let vector = stats.vector.as_ref().ok_or_else(|| {
3307            RedDBError::Internal("vector executor did not provide measurements".to_string())
3308        })?;
3309        let fields = vec![
3310            ("op", Value::text(vector.access_path.clone())),
3311            ("source", Value::text(source)),
3312            ("index_used", Value::Boolean(vector.index_used)),
3313            ("mode_requested", Value::text(vector.mode_requested.clone())),
3314            ("mode_executed", Value::text(vector.mode_executed.clone())),
3315            (
3316                "fallback_reason",
3317                vector
3318                    .fallback_reason
3319                    .as_ref()
3320                    .map_or(Value::Null, |reason| Value::text(reason.clone())),
3321            ),
3322            (
3323                "approximate_distance_evaluations",
3324                Value::UnsignedInteger(vector.approximate_distance_evaluations),
3325            ),
3326            (
3327                "peak_topk_entries",
3328                Value::UnsignedInteger(vector.peak_topk_entries),
3329            ),
3330            (
3331                "candidates_examined",
3332                Value::UnsignedInteger(vector.candidates_examined),
3333            ),
3334            (
3335                "metadata_rejected",
3336                Value::UnsignedInteger(vector.metadata_rejected),
3337            ),
3338            ("rls_rejected", Value::UnsignedInteger(vector.rls_rejected)),
3339            (
3340                "visibility_rejected",
3341                Value::UnsignedInteger(vector.visibility_rejected),
3342            ),
3343            (
3344                "exact_distance_evaluations",
3345                Value::UnsignedInteger(vector.exact_distance_evaluations),
3346            ),
3347            ("actual_rows", Value::UnsignedInteger(vector.rows_returned)),
3348            (
3349                "actual_ms",
3350                Value::Float(stats.exec_time_us as f64 / 1000.0),
3351            ),
3352            ("metrics_scope", Value::text("vector_pipeline")),
3353            (
3354                "operators",
3355                Value::Json(vector.operators_json().to_string().into_bytes()),
3356            ),
3357        ];
3358        let columns = fields.iter().map(|(name, _)| name.to_string()).collect();
3359        let mut record = crate::storage::query::unified::UnifiedRecord::default();
3360        for (name, value) in fields {
3361            record.set_owned(name.to_string(), value);
3362        }
3363        Ok(RuntimeQueryResult {
3364            query: raw_query.to_string(),
3365            mode: execution.mode,
3366            statement: "explain_analyze",
3367            engine: "runtime-explain-analyze",
3368            result: crate::storage::query::unified::UnifiedResult {
3369                columns,
3370                records: vec![record],
3371                stats,
3372                pre_serialized_json: None,
3373            },
3374            affected_rows: 0,
3375            statement_type: "select",
3376            bookmark: None,
3377            notice: None,
3378        })
3379    }
3380
3381    fn explain_analyze_as_rows(
3382        &self,
3383        raw_query: &str,
3384        inner_sql: &str,
3385    ) -> RedDBResult<RuntimeQueryResult> {
3386        if strip_keyword_ci(inner_sql.trim_start(), "VECTOR").is_some() {
3387            let expr = parse_multi(inner_sql).map_err(|err| RedDBError::Query(err.to_string()))?;
3388            if matches!(&expr, QueryExpr::Vector(_)) {
3389                return self.explain_analyze_vector_as_rows(raw_query, expr);
3390            }
3391        }
3392        if !starts_with_dml_keyword(inner_sql) {
3393            return Err(RedDBError::Query(
3394                "EXPLAIN ANALYZE currently supports INSERT, UPDATE, DELETE, and VECTOR SEARCH"
3395                    .to_string(),
3396            ));
3397        }
3398
3399        self.check_explain_target_privilege(inner_sql)?;
3400        let explain = self.explain_query(inner_sql)?;
3401        let conn_id = current_connection_id();
3402        if self.inner.transaction_state.in_transaction(conn_id) {
3403            return Err(RedDBError::Query(
3404                "EXPLAIN ANALYZE requires no active transaction".to_string(),
3405            ));
3406        }
3407
3408        self.execute_query_inner("BEGIN")?;
3409        let started = std::time::Instant::now();
3410        let execution = self.execute_query_inner(inner_sql);
3411        let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
3412        let rollback = self.execute_query_inner("ROLLBACK");
3413
3414        let affected_rows = match execution {
3415            Ok(result) => result.affected_rows,
3416            Err(err) => {
3417                rollback?;
3418                return Err(err);
3419            }
3420        };
3421        rollback?;
3422
3423        let columns = vec![
3424            "op".to_string(),
3425            "source".to_string(),
3426            "estimated_rows".to_string(),
3427            "estimated_cost".to_string(),
3428            "actual_rows".to_string(),
3429            "actual_ms".to_string(),
3430            "depth".to_string(),
3431        ];
3432        let mut records = Vec::new();
3433        walk_analyze_plan_node(
3434            &explain.logical_plan.root,
3435            0,
3436            affected_rows,
3437            elapsed_ms,
3438            &mut records,
3439        );
3440
3441        let result = crate::storage::query::unified::UnifiedResult {
3442            columns,
3443            records,
3444            stats: Default::default(),
3445            pre_serialized_json: None,
3446        };
3447
3448        Ok(RuntimeQueryResult {
3449            query: raw_query.to_string(),
3450            mode: explain.mode,
3451            statement: "explain_analyze",
3452            engine: "runtime-explain-analyze",
3453            result,
3454            affected_rows: 0,
3455            statement_type: "select",
3456            bookmark: None,
3457            notice: None,
3458        })
3459    }
3460}
3461
3462fn strip_explain_analyze_prefix(sql: &str) -> Option<&str> {
3463    let rest = strip_keyword_ci(sql.trim_start(), "EXPLAIN")?.trim_start();
3464    Some(strip_keyword_ci(rest, "ANALYZE")?.trim_start()).filter(|inner| !inner.is_empty())
3465}
3466
3467fn strip_keyword_ci<'a>(sql: &'a str, keyword: &str) -> Option<&'a str> {
3468    // `get` is None past the end or inside a multi-byte character, where
3469    // `split_at` would panic (and a release build aborts on panic).
3470    let head = sql.get(..keyword.len())?;
3471    let rest = &sql[keyword.len()..];
3472    if !head.eq_ignore_ascii_case(keyword) {
3473        return None;
3474    }
3475    if rest
3476        .chars()
3477        .next()
3478        .is_some_and(|ch| !ch.is_ascii_whitespace())
3479    {
3480        return None;
3481    }
3482    Some(rest)
3483}
3484
3485fn starts_with_dml_keyword(sql: &str) -> bool {
3486    let trimmed = sql.trim_start();
3487    let head_end = trimmed
3488        .find(|ch: char| ch.is_ascii_whitespace())
3489        .unwrap_or(trimmed.len());
3490    let head = &trimmed[..head_end];
3491    head.eq_ignore_ascii_case("INSERT")
3492        || head.eq_ignore_ascii_case("UPDATE")
3493        || head.eq_ignore_ascii_case("DELETE")
3494}
3495
3496fn walk_plan_node(
3497    node: &crate::storage::query::planner::CanonicalLogicalNode,
3498    depth: usize,
3499    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3500    vector_details: bool,
3501) {
3502    use std::sync::Arc;
3503    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3504    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3505    rec.set_arc(
3506        Arc::from("source"),
3507        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3508    );
3509    rec.set_arc(
3510        Arc::from("estimated_rows"),
3511        Value::Float(node.estimated_rows),
3512    );
3513    rec.set_arc(
3514        Arc::from("estimated_cost"),
3515        Value::Float(node.operator_cost),
3516    );
3517    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3518    if vector_details {
3519        for name in ["access_path_reason", "index_hint"] {
3520            rec.set_owned(
3521                name.to_string(),
3522                node.details
3523                    .get(name)
3524                    .cloned()
3525                    .map(Value::text)
3526                    .unwrap_or(Value::Null),
3527            );
3528        }
3529    }
3530    out.push(rec);
3531    for child in &node.children {
3532        walk_plan_node(child, depth + 1, out, vector_details);
3533    }
3534}
3535
3536fn walk_analyze_plan_node(
3537    node: &crate::storage::query::planner::CanonicalLogicalNode,
3538    depth: usize,
3539    affected_rows: u64,
3540    elapsed_ms: f64,
3541    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3542) {
3543    use std::sync::Arc;
3544    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3545    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3546    rec.set_arc(
3547        Arc::from("source"),
3548        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3549    );
3550    rec.set_arc(
3551        Arc::from("estimated_rows"),
3552        Value::Float(node.estimated_rows),
3553    );
3554    rec.set_arc(
3555        Arc::from("estimated_cost"),
3556        Value::Float(node.operator_cost),
3557    );
3558    rec.set_arc(
3559        Arc::from("actual_rows"),
3560        Value::UnsignedInteger(if depth == 0 { affected_rows } else { 0 }),
3561    );
3562    rec.set_arc(
3563        Arc::from("actual_ms"),
3564        Value::Float(if depth == 0 { elapsed_ms } else { 0.0 }),
3565    );
3566    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3567    out.push(rec);
3568
3569    for child in &node.children {
3570        walk_analyze_plan_node(child, depth + 1, 0, 0.0, out);
3571    }
3572}
3573
3574#[cfg(test)]
3575mod multibyte_sql_tests {
3576    use super::*;
3577
3578    #[test]
3579    fn explain_analyze_prefix_ignores_multibyte_text_at_the_keyword_boundary() {
3580        // `strip_keyword_ci` used `split_at(keyword.len())`, which panics when
3581        // that byte offset is inside a multi-byte character.
3582        for pad in 0..16 {
3583            for wide in ["é", "日", "😀"] {
3584                let sql = format!("{}{wide}llo", "a".repeat(pad));
3585                assert_eq!(strip_explain_analyze_prefix(&sql), None, "{sql:?}");
3586            }
3587        }
3588        assert_eq!(
3589            strip_explain_analyze_prefix("explain  analyze DELETE FROM t"),
3590            Some("DELETE FROM t")
3591        );
3592    }
3593
3594    /// One malformed or unusual statement must never take the process down:
3595    /// release builds abort on panic, so any panic reachable from
3596    /// `execute_query` is a remote denial of service. Put a multi-byte
3597    /// character at every byte offset a keyword matcher might slice at.
3598    #[test]
3599    fn multibyte_sql_never_panics_execute_query() {
3600        let rt = RedDBRuntime::in_memory().expect("in-memory runtime");
3601        let seeds = [
3602            "",
3603            "SELECT ",
3604            "SELECT '",
3605            "EXPLAIN ",
3606            "EXPLAIN ANALYZE ",
3607            "CHECKPOINT ",
3608            "CHECKOUT ",
3609            "RESET ",
3610            "MERGE ",
3611            "CHERRY PICK ",
3612            "REVERT ",
3613            "RESOLVE CONFLICT ",
3614            "INSERT INTO t VALUES ('",
3615        ];
3616        let mut panicked = Vec::new();
3617        for seed in seeds {
3618            for pad in 0..16 {
3619                for wide in ["é", "日", "😀"] {
3620                    let sql = format!("{seed}{}{wide}llo", "a".repeat(pad));
3621                    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3622                        let _ = rt.execute_query(&sql);
3623                    }));
3624                    if outcome.is_err() {
3625                        panicked.push(sql);
3626                    }
3627                }
3628            }
3629        }
3630        assert!(
3631            panicked.is_empty(),
3632            "execute_query panicked on {} statement(s), first: {:?}",
3633            panicked.len(),
3634            panicked.first()
3635        );
3636    }
3637}
3638
3639#[cfg(test)]
3640mod inline_graph_tvf_tests {
3641    use super::*;
3642
3643    fn scopes_for(sql: &str) -> HashSet<String> {
3644        let expr = reddb_rql::parser::parse(sql).expect("parse").query;
3645        query_expr_result_cache_scopes(&expr)
3646    }
3647
3648    #[test]
3649    fn inline_tvf_cache_scopes_include_source_collections() {
3650        // The result-cache key for the inline form must derive from the
3651        // `nodes`/`edges` source collections so a write to either invalidates
3652        // the cached result (issue #799).
3653        let scopes = scopes_for(
3654            "SELECT * FROM components(nodes => (SELECT id FROM hosts), edges => (SELECT src, dst FROM links))",
3655        );
3656        assert!(scopes.contains("hosts"), "nodes source scoped: {scopes:?}");
3657        assert!(scopes.contains("links"), "edges source scoped: {scopes:?}");
3658    }
3659
3660    #[test]
3661    fn graph_collection_tvf_cache_scope_is_graph_argument() {
3662        // The graph-collection form still materializes the active graph, but
3663        // result-cache invalidation is scoped to the named graph argument so
3664        // INSERT INTO g NODE/EDGE invalidates cached TVF rows.
3665        let scopes = scopes_for("SELECT * FROM components(g)");
3666        assert!(scopes.contains("g"), "collection form scoped: {scopes:?}");
3667    }
3668
3669    #[test]
3670    fn abstract_degree_centrality_counts_undirected_endpoints() {
3671        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
3672        let edges = vec![
3673            ("a".to_string(), "b".to_string(), 1.0_f32),
3674            ("b".to_string(), "c".to_string(), 1.0_f32),
3675        ];
3676        let degrees = abstract_degree_centrality(&nodes, &edges);
3677        assert_eq!(
3678            degrees,
3679            vec![
3680                ("a".to_string(), 1),
3681                ("b".to_string(), 2),
3682                ("c".to_string(), 1),
3683            ]
3684        );
3685    }
3686}
3687
3688/// Vault keys the engine itself owns. `SET SECRET` / `DELETE SECRET` share
3689/// the vault KV space with the IAM policy blobs, the attachment tables and
3690/// the `SECRET()` column master key; letting a principal with `secret:write`
3691/// overwrite them is an escalation (allow-all policy after restart) or a
3692/// permanent data loss (rotated AES key). Provider credentials under
3693/// `red.secret.ai.*` stay writable — that is the documented use.
3694fn reject_engine_internal_vault_key(key: &str) -> RedDBResult<()> {
3695    let lowered = key.to_ascii_lowercase();
3696    let internal = lowered == "red.secret.aes_key"
3697        || lowered.starts_with("red.iam.")
3698        || lowered.starts_with("red.acl.")
3699        || lowered.starts_with("red.vault.");
3700    if internal {
3701        return Err(RedDBError::Query(format!(
3702            "`{key}` is an engine-internal vault key and cannot be set or deleted through SET/DELETE SECRET"
3703        )));
3704    }
3705    Ok(())
3706}
3707
3708/// Optional confinement for `COPY … FROM '<path>'`. When
3709/// `REDDB_COPY_IMPORT_ROOT` is set, the (canonicalised) source path must live
3710/// under that directory; otherwise the statement is refused. Unset keeps the
3711/// historical behaviour (any readable file, admin-only via the privilege
3712/// gate).
3713fn confine_copy_from_path(path: &str) -> RedDBResult<()> {
3714    let Ok(root) = std::env::var("REDDB_COPY_IMPORT_ROOT") else {
3715        return Ok(());
3716    };
3717    let root = std::fs::canonicalize(&root).map_err(|e| {
3718        RedDBError::Query(format!(
3719            "REDDB_COPY_IMPORT_ROOT `{root}` is not usable: {e}"
3720        ))
3721    })?;
3722    let resolved = std::fs::canonicalize(path)
3723        .map_err(|e| RedDBError::Query(format!("COPY FROM `{path}`: {e}")))?;
3724    if !resolved.starts_with(&root) || !resolved.is_file() {
3725        return Err(RedDBError::Query(format!(
3726            "COPY FROM `{path}` is outside the configured import root"
3727        )));
3728    }
3729    Ok(())
3730}
3731
3732#[cfg(test)]
3733mod security_gate_tests {
3734    use super::*;
3735    use crate::auth::store::AuthStore;
3736    use crate::auth::{AuthConfig, Role};
3737
3738    /// Clears the thread-local identity on drop so one test's principal
3739    /// never leaks into the next on a shared thread.
3740    struct IdentityGuard;
3741
3742    impl IdentityGuard {
3743        fn install(username: &str, role: Role) -> Self {
3744            set_current_auth_identity(username.to_string(), role);
3745            Self
3746        }
3747    }
3748
3749    impl Drop for IdentityGuard {
3750        fn drop(&mut self) {
3751            clear_current_auth_identity();
3752        }
3753    }
3754
3755    fn runtime_with_user(username: &str, role: Role) -> RedDBRuntime {
3756        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
3757            enabled: true,
3758            session_ttl_secs: 60,
3759            require_auth: true,
3760            auto_encrypt_storage: false,
3761            vault_enabled: false,
3762            cert: Default::default(),
3763            oauth: Default::default(),
3764        }));
3765        store.create_user(username, "pw", role).expect("user");
3766        let runtime =
3767            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
3768        runtime.set_auth_store(store);
3769        runtime
3770    }
3771
3772    fn runtime_with_store(username: &str, role: Role) -> (RedDBRuntime, std::sync::Arc<AuthStore>) {
3773        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
3774            enabled: true,
3775            session_ttl_secs: 60,
3776            require_auth: true,
3777            auto_encrypt_storage: false,
3778            vault_enabled: false,
3779            cert: Default::default(),
3780            oauth: Default::default(),
3781        }));
3782        store.create_user(username, "pw", role).expect("user");
3783        let runtime =
3784            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
3785        runtime.set_auth_store(std::sync::Arc::clone(&store));
3786        (runtime, store)
3787    }
3788
3789    #[test]
3790    fn nested_reads_need_select_on_the_inner_table() {
3791        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
3792        use crate::auth::UserId;
3793        let (runtime, store) = runtime_with_store("ro", Role::Read);
3794        runtime
3795            .execute_query("CREATE TABLE gate_open (a INTEGER)")
3796            .expect("create");
3797        runtime
3798            .execute_query("CREATE TABLE gate_closed (b INTEGER)")
3799            .expect("create");
3800        // One explicit grant switches the legacy fallback off, so `ro`
3801        // may read exactly `gate_open`.
3802        store
3803            .grant(
3804                &UserId::from_parts(None, "admin"),
3805                Role::Admin,
3806                GrantPrincipal::User(UserId::from_parts(None, "ro")),
3807                Resource::table_from_name("gate_open"),
3808                vec![Action::Select],
3809                false,
3810                None,
3811            )
3812            .expect("grant");
3813        let _identity = IdentityGuard::install("ro", Role::Read);
3814        runtime
3815            .execute_query("SELECT * FROM (SELECT a FROM gate_open) s")
3816            .expect("derived table over a granted table is fine");
3817        // The outer statement names only the synthetic subquery table, so
3818        // the inner table used to be read with no check at all.
3819        for sql in [
3820            "SELECT * FROM (SELECT b FROM gate_closed) s",
3821            "SELECT a FROM gate_open WHERE a IN (SELECT b FROM gate_closed)",
3822        ] {
3823            let err = runtime
3824                .execute_query(sql)
3825                .expect_err("nested read of an ungranted table must be refused");
3826            assert!(
3827                err.to_string().contains("permission denied"),
3828                "{sql}: unexpected error: {err}"
3829            );
3830        }
3831    }
3832
3833    #[test]
3834    fn policy_listings_are_self_or_policy_management() {
3835        let (runtime, store) = runtime_with_store("ro", Role::Read);
3836        store.create_user("other", "pw", Role::Read).expect("user");
3837        let _identity = IdentityGuard::install("ro", Role::Read);
3838        runtime
3839            .execute_query("SHOW EFFECTIVE PERMISSIONS FOR ro")
3840            .expect("a principal may inspect its own permissions");
3841        let err = runtime
3842            .execute_query("SHOW EFFECTIVE PERMISSIONS FOR other")
3843            .expect_err("inspecting another principal is policy management");
3844        assert!(
3845            err.to_string().contains("permission denied"),
3846            "unexpected error: {err}"
3847        );
3848    }
3849
3850    #[test]
3851    fn secret_columns_stay_masked_for_readers_without_secret_read() {
3852        use reddb_types::Value;
3853        let (runtime, store) = runtime_with_store("ro", Role::Read);
3854        store.create_user("root", "pw", Role::Admin).expect("admin");
3855        store.ensure_vault_secret_key();
3856        runtime
3857            .execute_query(
3858                "INSERT INTO creds_gate (name, token) VALUES ('stripe', SECRET('sk_live_abc'))",
3859            )
3860            .expect("insert");
3861
3862        let token_for = |username: &str, role: Role| {
3863            let _identity = IdentityGuard::install(username, role);
3864            let result = runtime
3865                .execute_query("SELECT name, token FROM creds_gate")
3866                .expect("select");
3867            result.result.records[0]
3868                .get("token")
3869                .cloned()
3870                .expect("token column")
3871        };
3872        // Ordinary SELECT stays masked for every role. Plaintext retrieval
3873        // requires an explicit reveal operation.
3874        assert!(
3875            matches!(token_for("ro", Role::Read), Value::Secret(_)),
3876            "a read-role principal must see the column masked"
3877        );
3878        assert!(matches!(token_for("root", Role::Admin), Value::Secret(_)));
3879    }
3880
3881    #[test]
3882    fn store_wide_operations_need_the_admin_tier() {
3883        let runtime = runtime_with_user("bob", Role::Write);
3884        let _identity = IdentityGuard::install("bob", Role::Write);
3885        // These fell through the gate's wildcard arm, so a write-role
3886        // principal could scrub or vacuum the store.
3887        for sql in ["VACUUM", "SCRUB"] {
3888            let err = runtime
3889                .execute_query(sql)
3890                .expect_err("store-wide operation must be refused for a non-admin");
3891            assert!(
3892                err.to_string().contains("permission denied"),
3893                "{sql}: unexpected error: {err}"
3894            );
3895        }
3896    }
3897
3898    #[test]
3899    fn explain_needs_the_privilege_of_the_statement_it_plans() {
3900        let runtime = runtime_with_user("ro", Role::Read);
3901        let _identity = IdentityGuard::install("ro", Role::Read);
3902        runtime
3903            .execute_query("EXPLAIN SELECT 1 AS one")
3904            .expect("planning a read is a read");
3905        // EXPLAIN used to bypass the gate entirely, so a read-only
3906        // principal could plan any write and read the schema back.
3907        let err = runtime
3908            .execute_query("EXPLAIN INSERT INTO gate_t (a) VALUES (1)")
3909            .expect_err("planning a write needs the write privilege");
3910        assert!(
3911            err.to_string().contains("permission denied"),
3912            "unexpected EXPLAIN error: {err}"
3913        );
3914    }
3915
3916    #[test]
3917    fn copy_from_a_server_path_is_admin_only() {
3918        let runtime = runtime_with_user("bob", Role::Write);
3919        let _identity = IdentityGuard::install("bob", Role::Write);
3920        // `COPY … FROM '<path>'` opens a file on the *server*, so a
3921        // write-role principal used to be able to read /proc/self/environ
3922        // (admin token, vault certificate, cloud keys) into a table.
3923        let err = runtime
3924            .execute_query("COPY leak FROM '/etc/hostname'")
3925            .expect_err("COPY FROM must be refused for a non-admin");
3926        assert!(
3927            err.to_string().contains("permission denied"),
3928            "unexpected COPY error: {err}"
3929        );
3930    }
3931
3932    #[test]
3933    fn vcs_history_rewrites_are_admin_only() {
3934        let runtime = runtime_with_user("bob", Role::Write);
3935        let _identity = IdentityGuard::install("bob", Role::Write);
3936        // VCS commands were dispatched before the statement frame was built,
3937        // so `RESET HARD TO` ran with no privilege check at all.
3938        let err = runtime
3939            .execute_query("RESET HARD TO 'deadbeef'")
3940            .expect_err("RESET must be refused for a non-admin");
3941        assert!(
3942            err.to_string().contains("permission denied"),
3943            "unexpected RESET error: {err}"
3944        );
3945    }
3946
3947    #[test]
3948    fn ai_provider_config_writes_are_admin_only() {
3949        let runtime = runtime_with_user("bob", Role::Write);
3950        let _identity = IdentityGuard::install("bob", Role::Write);
3951        // This namespace decides which vault secret is sent, as a bearer, to
3952        // which host — the exfiltration primitive behind the SET CONFIG gap.
3953        let err = runtime
3954            .execute_query(
3955                "SET CONFIG red.config.ai.providers.openai.tokens.default.secret_ref = 'red.secret.aes_key'",
3956            )
3957            .expect_err("AI provider config must be refused for a non-admin");
3958        assert!(
3959            err.to_string().contains("permission denied"),
3960            "unexpected SET CONFIG error: {err}"
3961        );
3962    }
3963
3964    #[test]
3965    fn engine_internal_vault_keys_are_not_writable_through_set_secret() {
3966        let runtime = runtime_with_user("root", Role::Admin);
3967        let _identity = IdentityGuard::install("root", Role::Admin);
3968        // Even an admin must not overwrite the IAM policy blob (an allow-all
3969        // policy would survive a restart) or the `SECRET()` master key
3970        // (rotating it destroys every encrypted column value).
3971        for key in [
3972            "red.iam.policies",
3973            "red.iam.attachments.users",
3974            "red.secret.aes_key",
3975        ] {
3976            let err = runtime
3977                .execute_query(&format!("SET SECRET {key} = 'x'"))
3978                .expect_err("engine-internal vault key must be refused");
3979            assert!(
3980                err.to_string().contains("engine-internal"),
3981                "unexpected error for {key}: {err}"
3982            );
3983        }
3984    }
3985}