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                        // The cutoff protects snapshots, not physical references.
2242                        // Aborted inserts can remain in this or another collection;
2243                        // forgetting their outcome would make them visible again.
2244                        // Stats refresh covers every target (same as ANALYZE).
2245                        for t in &targets {
2246                            self.refresh_table_planner_stats(t);
2247                        }
2248                        // FULL forces a pager persist (dirty-page flush + fsync).
2249                        // Regular VACUUM relies on the background writer / segment
2250                        // lifecycle so the command is non-blocking.
2251                        let persisted = if *full {
2252                            match store.persist() {
2253                                Ok(()) => true,
2254                                Err(e) => {
2255                                    return Err(RedDBError::Internal(format!(
2256                                        "VACUUM FULL persist failed: {e:?}"
2257                                    )));
2258                                }
2259                            }
2260                        } else {
2261                            false
2262                        };
2263                        // Result cache depended on pre-vacuum state.
2264                        self.invalidate_result_cache();
2265                        (
2266                            "vacuum",
2267                            format!(
2268                                "VACUUM{} processed {} table(s): scanned_versions={}, retained_versions={}, reclaimed_versions={}, retained_history_versions={}, reclaimed_history_versions={}, retained_tombstones={}, reclaimed_tombstones={}{}",
2269                                if *full { " FULL" } else { "" },
2270                                targets.len(),
2271                                vacuum_stats.scanned_versions,
2272                                vacuum_stats.retained_versions,
2273                                vacuum_stats.reclaimed_versions,
2274                                vacuum_stats.retained_history_versions,
2275                                vacuum_stats.reclaimed_history_versions,
2276                                vacuum_stats.retained_tombstones,
2277                                vacuum_stats.reclaimed_tombstones,
2278                                if persisted {
2279                                    " (pages flushed to disk)"
2280                                } else {
2281                                    ""
2282                                }
2283                            ),
2284                        )
2285                    }
2286                };
2287                Ok(RuntimeQueryResult::ok_message(
2288                    query.to_string(),
2289                    &msg,
2290                    kind,
2291                ))
2292            }
2293            // GRANT / REVOKE / ALTER USER (RBAC milestone).
2294            //
2295            // These hit the AuthStore directly. The statement frame /
2296            // privilege gate has already decided whether the caller may
2297            // even run the statement; here we just translate the AST into
2298            // AuthStore calls.
2299            QueryExpr::Grant(ref g) => self.execute_grant_statement(query, g),
2300            QueryExpr::Revoke(ref r) => self.execute_revoke_statement(query, r),
2301            QueryExpr::AlterUser(ref a) => self.execute_alter_user_statement(query, a),
2302            QueryExpr::CreateUser(ref u) => self.execute_create_user_statement(query, u),
2303            QueryExpr::CreateIamPolicy { ref id, ref json } => {
2304                self.execute_create_iam_policy(query, id, json)
2305            }
2306            QueryExpr::DropIamPolicy { ref id } => self.execute_drop_iam_policy(query, id),
2307            QueryExpr::AttachPolicy {
2308                ref policy_id,
2309                ref principal,
2310            } => self.execute_attach_policy(query, policy_id, principal),
2311            QueryExpr::DetachPolicy {
2312                ref policy_id,
2313                ref principal,
2314            } => self.execute_detach_policy(query, policy_id, principal),
2315            QueryExpr::ShowPolicies { ref filter } => {
2316                self.execute_show_policies(query, filter.as_ref())
2317            }
2318            QueryExpr::ShowEffectivePermissions {
2319                ref user,
2320                ref resource,
2321            } => self.execute_show_effective_permissions(query, user, resource.as_ref()),
2322            QueryExpr::SimulatePolicy {
2323                ref user,
2324                ref action,
2325                ref resource,
2326            } => self.execute_simulate_policy(query, user, action, resource),
2327            QueryExpr::LintPolicy { ref source } => self.execute_lint_policy(query, source),
2328            QueryExpr::MigratePolicyMode {
2329                ref target,
2330                dry_run,
2331            } => self.execute_migrate_policy_mode(query, target, dry_run),
2332            QueryExpr::CreateMigration(ref q) => self.execute_create_migration(query, q),
2333            QueryExpr::ApplyMigration(ref q) => self.execute_apply_migration(query, q),
2334            QueryExpr::RollbackMigration(ref q) => self.execute_rollback_migration(query, q),
2335            QueryExpr::ExplainMigration(ref q) => self.execute_explain_migration(query, q),
2336            _ => Err(RedDBError::Query(
2337                "unsupported command in runtime dispatcher".to_string(),
2338            )),
2339        };
2340
2341        if !control_event_specs.is_empty() {
2342            let (outcome, reason) = match &query_result {
2343                Ok(_) => (crate::runtime::control_events::Outcome::Allowed, None),
2344                Err(err) => (control_event_outcome_for_error(err), Some(err.to_string())),
2345            };
2346            for spec in &control_event_specs {
2347                self.emit_control_event(
2348                    spec.kind,
2349                    outcome,
2350                    spec.action,
2351                    spec.resource.clone(),
2352                    reason.clone(),
2353                    spec.fields.clone(),
2354                )?;
2355            }
2356        }
2357
2358        if let (Some(plan), Ok(result)) = (&query_audit_plan, &query_result) {
2359            self.emit_query_audit(
2360                query,
2361                plan,
2362                query_audit_started.elapsed().as_millis() as u64,
2363                result,
2364            );
2365        }
2366
2367        // Cache SELECT results for 30s.
2368        // Skip: pre-serialized JSON (large clone), and result sets > 5 rows.
2369        // Large multi-row results (range scans, filtered scans) are rarely
2370        // repeated with the same literal values so the cache hit rate is near
2371        // zero while the clone cost (100 records × ~16 fields each) is high.
2372        // Aggregations (1 row) and point lookups (1 row) still benefit.
2373        if let Ok(ref result) = query_result {
2374            frame.write_result_cache(self, result, result_cache_scopes);
2375        }
2376
2377        query_result
2378    }
2379
2380    /// Execute a pre-parsed `QueryExpr` that has no accompanying SQL text,
2381    /// bypassing SQL parsing and the plan cache.
2382    ///
2383    /// Prefer [`Self::execute_prepared_query`] or
2384    /// [`Self::execute_query_with_params`]: they carry the statement text, so
2385    /// the frame's snapshot (including `AS OF`), coarse privilege class, lock
2386    /// intent, and result-cache key all describe the statement that actually
2387    /// runs. This entry exists for the nested in-statement callers — the CTE
2388    /// prelude, materialized-view refresh, and the inline graph TVF — which
2389    /// hold an expression the outer statement already parsed.
2390    ///
2391    /// Cost: this is no longer a "zero overhead" path. It builds and installs
2392    /// a full `StatementExecutionFrame` (tenant / config / secret / KV guards
2393    /// plus an MVCC snapshot), which is what buys it the same snapshot
2394    /// isolation as textual SQL. Nested calls inherit the enclosing
2395    /// statement's snapshot rather than minting a new one, and the frame opts
2396    /// out of the result cache because there is no stable text key.
2397    ///
2398    /// Keeps secret SELECT results masked, identical to `execute_query`.
2399    pub fn execute_query_expr(&self, expr: QueryExpr) -> RedDBResult<RuntimeQueryResult> {
2400        let frame = super::statement_frame::StatementExecutionFrame::build(
2401            self,
2402            super::statement_frame::StatementIdentity::Expr(&expr),
2403        )?;
2404        let _frame_guards = Box::new(frame.install(self));
2405        // View rewrite (Phase 2.1): substitute any `QueryExpr::Table(tq)`
2406        // whose `tq.table` matches a registered view with the view's
2407        // underlying query. Safe to call even when no views are registered.
2408        let expr = self.rewrite_view_refs(expr);
2409
2410        self.validate_model_operations_before_auth(&expr)?;
2411        // Granular RBAC privilege check. Runs before dispatch so a
2412        // denied caller never reaches storage. Fail-closed: any error
2413        // resolving the action / resource produces PermissionDenied.
2414        if let Err(err) = self.check_query_privilege(&expr) {
2415            return Err(RedDBError::Query(format!("permission denied: {err}")));
2416        }
2417
2418        let statement = query_expr_name(&expr);
2419        let mode = detect_mode(statement);
2420        let query_str = statement;
2421
2422        self.dispatch_expr(expr, query_str, mode)
2423    }
2424
2425    pub(super) fn validate_model_operations_before_auth(
2426        &self,
2427        expr: &QueryExpr,
2428    ) -> RedDBResult<()> {
2429        self.validate_vault_table_access(expr)?;
2430        use crate::catalog::CollectionModel;
2431        use crate::runtime::ddl::polymorphic_resolver;
2432        use reddb_rql::ast::KvCommand;
2433
2434        let system_schema_target = match expr {
2435            QueryExpr::DropTable(q) => Some(q.name.as_str()),
2436            QueryExpr::DropGraph(q) => Some(q.name.as_str()),
2437            QueryExpr::DropVector(q) => Some(q.name.as_str()),
2438            QueryExpr::DropDocument(q) => Some(q.name.as_str()),
2439            QueryExpr::DropKv(q) => Some(q.name.as_str()),
2440            QueryExpr::DropCollection(q) => Some(q.name.as_str()),
2441            QueryExpr::Truncate(q) => Some(q.name.as_str()),
2442            _ => None,
2443        };
2444        if system_schema_target.is_some_and(crate::runtime::impl_ddl::is_system_schema_name) {
2445            return Err(RedDBError::Query("system schema is read-only".to_string()));
2446        }
2447
2448        let expected = match expr {
2449            QueryExpr::DropTable(q) => Some((q.name.as_str(), CollectionModel::Table)),
2450            QueryExpr::DropGraph(q) => Some((q.name.as_str(), CollectionModel::Graph)),
2451            QueryExpr::DropVector(q) => Some((q.name.as_str(), CollectionModel::Vector)),
2452            QueryExpr::DropDocument(q) => Some((q.name.as_str(), CollectionModel::Document)),
2453            QueryExpr::DropKv(q) => Some((q.name.as_str(), q.model)),
2454            QueryExpr::DropCollection(q) => q.model.map(|model| (q.name.as_str(), model)),
2455            QueryExpr::Truncate(q) => q.model.map(|model| (q.name.as_str(), model)),
2456            QueryExpr::KvCommand(cmd) => {
2457                let (collection, model) = match cmd {
2458                    KvCommand::Put {
2459                        collection, model, ..
2460                    }
2461                    | KvCommand::Get {
2462                        collection, model, ..
2463                    }
2464                    | KvCommand::Incr {
2465                        collection, model, ..
2466                    }
2467                    | KvCommand::Cas {
2468                        collection, model, ..
2469                    }
2470                    | KvCommand::List {
2471                        collection, model, ..
2472                    }
2473                    | KvCommand::Delete {
2474                        collection, model, ..
2475                    } => (collection.as_str(), *model),
2476                    KvCommand::Rotate { collection, .. }
2477                    | KvCommand::History { collection, .. }
2478                    | KvCommand::Purge { collection, .. } => {
2479                        (collection.as_str(), CollectionModel::Vault)
2480                    }
2481                    KvCommand::InvalidateTags { collection, .. } => {
2482                        (collection.as_str(), CollectionModel::Kv)
2483                    }
2484                    KvCommand::Watch {
2485                        collection, model, ..
2486                    } => (collection.as_str(), *model),
2487                    KvCommand::Reveal { collection, .. } => {
2488                        (collection.as_str(), CollectionModel::Vault)
2489                    }
2490                };
2491                Some((collection, model))
2492            }
2493            QueryExpr::ConfigCommand(cmd) => {
2494                self.validate_config_command_before_auth(cmd)?;
2495                None
2496            }
2497            _ => None,
2498        };
2499
2500        let Some((name, expected_model)) = expected else {
2501            return Ok(());
2502        };
2503        let snapshot = self.inner.db.catalog_model_snapshot();
2504        let Some(actual_model) = snapshot
2505            .collections
2506            .iter()
2507            .find(|collection| collection.name == name)
2508            .map(|collection| collection.declared_model.unwrap_or(collection.model))
2509        else {
2510            return Ok(());
2511        };
2512        polymorphic_resolver::ensure_model_match(expected_model, actual_model)
2513    }
2514
2515    /// Walk a `QueryExpr` and replace `QueryExpr::Table(tq)` nodes whose
2516    /// `tq.table` matches a registered view name with the view's stored
2517    /// body. Recurses through joins so `SELECT ... FROM t JOIN myview ...`
2518    /// resolves correctly. Pure operation — no side effects.
2519    pub(super) fn rewrite_view_refs(&self, expr: QueryExpr) -> QueryExpr {
2520        // Fast path: no views registered → return original expression.
2521        if self.inner.views.read().is_empty() {
2522            return expr;
2523        }
2524        self.rewrite_view_refs_inner(expr)
2525    }
2526
2527    fn rewrite_view_refs_inner(&self, expr: QueryExpr) -> QueryExpr {
2528        use reddb_rql::ast::{Filter, TableSource};
2529        match expr {
2530            QueryExpr::Table(mut tq) => {
2531                // 1. If the TableSource is a subquery, recurse into it so
2532                //    `SELECT ... FROM (SELECT ... FROM myview) t` expands.
2533                //    The legacy `table` field (set to a synthetic
2534                //    "__subq_NNNN" sentinel) stays as-is so callers that
2535                //    read it keep compiling.
2536                if let Some(TableSource::Subquery(body)) = tq.source.take() {
2537                    tq.source = Some(TableSource::Subquery(Box::new(
2538                        self.rewrite_view_refs_inner(*body),
2539                    )));
2540                    return QueryExpr::Table(tq);
2541                }
2542
2543                // 2. Restore the source field (took it above for match).
2544                // When the source was `None` or `TableSource::Name(_)`, the
2545                // real lookup key is `tq.table` — check the view registry.
2546                let maybe_view = {
2547                    let views = self.inner.views.read();
2548                    views.get(&tq.table).cloned()
2549                };
2550                let Some(view) = maybe_view else {
2551                    return QueryExpr::Table(tq);
2552                };
2553
2554                // Issue #594 slice 9b — materialized views are read
2555                // from their backing collection, not by substituting
2556                // the body. Returning the TableQuery as-is lets the
2557                // normal table-read path resolve `SELECT FROM v`
2558                // against the collection provisioned at CREATE time.
2559                if view.materialized {
2560                    return QueryExpr::Table(tq);
2561                }
2562
2563                // Recurse into the view body — views may reference other
2564                // views. The recursion yields the final QueryExpr we need
2565                // to merge the outer's filter / limit / offset into.
2566                let inner_expr = self.rewrite_view_refs_inner((*view.query).clone());
2567
2568                // Phase 5: when the body is a Table we merge the outer
2569                // TableQuery's WHERE / LIMIT / OFFSET into it so stacked
2570                // views filter recursively. Non-table bodies (Search,
2571                // Ask, Vector, Graph, Hybrid) can't meaningfully combine
2572                // with an outer Table query today — return the body
2573                // verbatim; outer predicates are lost. Full projection
2574                // merge lands in Phase 5.2.
2575                match inner_expr {
2576                    QueryExpr::Table(mut inner_tq) => {
2577                        if let Some(outer_filter) = tq.filter.take() {
2578                            inner_tq.filter = Some(match inner_tq.filter.take() {
2579                                Some(existing) => {
2580                                    Filter::And(Box::new(existing), Box::new(outer_filter))
2581                                }
2582                                None => outer_filter,
2583                            });
2584                            // Keep the `Expr` form in lock-step with the
2585                            // merged `Filter`. The executor prefers
2586                            // `where_expr` and nulls `filter` when it is
2587                            // present (see `execute_query_inner`), so a
2588                            // stacked view whose outer predicate was only
2589                            // merged into `filter` would silently drop that
2590                            // predicate at eval time (#635).
2591                            inner_tq.where_expr = inner_tq
2592                                .filter
2593                                .as_ref()
2594                                .map(reddb_rql::sql_lowering::filter_to_expr);
2595                        }
2596                        if let Some(outer_limit) = tq.limit {
2597                            inner_tq.limit = Some(match inner_tq.limit {
2598                                Some(existing) => existing.min(outer_limit),
2599                                None => outer_limit,
2600                            });
2601                        }
2602                        if let Some(outer_offset) = tq.offset {
2603                            inner_tq.offset = Some(match inner_tq.offset {
2604                                Some(existing) => existing + outer_offset,
2605                                None => outer_offset,
2606                            });
2607                        }
2608                        QueryExpr::Table(inner_tq)
2609                    }
2610                    other => other,
2611                }
2612            }
2613            QueryExpr::Join(mut jq) => {
2614                jq.left = Box::new(self.rewrite_view_refs_inner(*jq.left));
2615                jq.right = Box::new(self.rewrite_view_refs_inner(*jq.right));
2616                QueryExpr::Join(jq)
2617            }
2618            // Other variants don't carry nested QueryExpr that can reference
2619            // a view by table name. Return as-is.
2620            other => other,
2621        }
2622    }
2623
2624    fn resolve_table_expr_subqueries(
2625        &self,
2626        mut table: TableQuery,
2627        frame: &dyn super::statement_frame::ReadFrame,
2628    ) -> RedDBResult<TableQuery> {
2629        // Only a `Subquery` source needs recursive resolution. `.take()`
2630        // would otherwise drop a `Name` / `Function` source on the floor
2631        // (the `if let` skips the body but the take already cleared it),
2632        // which silently broke `SELECT * FROM components(g)` — the TVF
2633        // dispatch downstream keys off `TableSource::Function` and never
2634        // fired. Restore any non-subquery source unchanged (issue #795).
2635        match table.source.take() {
2636            Some(TableSource::Subquery(inner)) => {
2637                let inner = self.resolve_select_expr_subqueries(*inner, frame)?;
2638                table.source = Some(TableSource::Subquery(Box::new(inner)));
2639            }
2640            other => table.source = other,
2641        }
2642
2643        let outer_scopes = relation_scopes_for_query(&QueryExpr::Table(table.clone()));
2644        for item in &mut table.select_items {
2645            if let reddb_rql::ast::SelectItem::Expr { expr, .. } = item {
2646                *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2647            }
2648        }
2649        if let Some(where_expr) = table.where_expr.take() {
2650            table.where_expr =
2651                Some(self.resolve_expr_subqueries(where_expr, &outer_scopes, frame)?);
2652            table.filter = None;
2653        }
2654        if let Some(having_expr) = table.having_expr.take() {
2655            table.having_expr =
2656                Some(self.resolve_expr_subqueries(having_expr, &outer_scopes, frame)?);
2657            table.having = None;
2658        }
2659        for expr in &mut table.group_by_exprs {
2660            *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2661        }
2662        for clause in &mut table.order_by {
2663            if let Some(expr) = clause.expr.take() {
2664                clause.expr = Some(self.resolve_expr_subqueries(expr, &outer_scopes, frame)?);
2665            }
2666        }
2667        Ok(table)
2668    }
2669
2670    fn resolve_select_expr_subqueries(
2671        &self,
2672        expr: QueryExpr,
2673        frame: &dyn super::statement_frame::ReadFrame,
2674    ) -> RedDBResult<QueryExpr> {
2675        match expr {
2676            QueryExpr::Table(table) => self
2677                .resolve_table_expr_subqueries(table, frame)
2678                .map(QueryExpr::Table),
2679            QueryExpr::Join(mut join) => {
2680                join.left = Box::new(self.resolve_select_expr_subqueries(*join.left, frame)?);
2681                join.right = Box::new(self.resolve_select_expr_subqueries(*join.right, frame)?);
2682                Ok(QueryExpr::Join(join))
2683            }
2684            other => Ok(other),
2685        }
2686    }
2687
2688    fn resolve_expr_subqueries(
2689        &self,
2690        expr: reddb_rql::ast::Expr,
2691        outer_scopes: &[String],
2692        frame: &dyn super::statement_frame::ReadFrame,
2693    ) -> RedDBResult<reddb_rql::ast::Expr> {
2694        use reddb_rql::ast::Expr;
2695
2696        match expr {
2697            Expr::Subquery { query, span } => {
2698                let values = self.execute_expr_subquery_values(query, outer_scopes, frame)?;
2699                if values.len() > 1 {
2700                    return Err(RedDBError::Query(
2701                        "scalar subquery returned more than one row".to_string(),
2702                    ));
2703                }
2704                Ok(Expr::Literal {
2705                    value: values.into_iter().next().unwrap_or(Value::Null),
2706                    span,
2707                })
2708            }
2709            Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
2710                op,
2711                lhs: Box::new(self.resolve_expr_subqueries(*lhs, outer_scopes, frame)?),
2712                rhs: Box::new(self.resolve_expr_subqueries(*rhs, outer_scopes, frame)?),
2713                span,
2714            }),
2715            Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
2716                op,
2717                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2718                span,
2719            }),
2720            Expr::Cast {
2721                inner,
2722                target,
2723                span,
2724            } => Ok(Expr::Cast {
2725                inner: Box::new(self.resolve_expr_subqueries(*inner, outer_scopes, frame)?),
2726                target,
2727                span,
2728            }),
2729            Expr::FunctionCall { name, args, span } => {
2730                let args = args
2731                    .into_iter()
2732                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2733                    .collect::<RedDBResult<Vec<_>>>()?;
2734                Ok(Expr::FunctionCall { name, args, span })
2735            }
2736            Expr::WindowFunctionCall {
2737                name,
2738                args,
2739                mut window,
2740                span,
2741            } => {
2742                let args = args
2743                    .into_iter()
2744                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2745                    .collect::<RedDBResult<Vec<_>>>()?;
2746                for expr in &mut window.partition_by {
2747                    *expr = self.resolve_expr_subqueries(expr.clone(), outer_scopes, frame)?;
2748                }
2749                for clause in &mut window.order_by {
2750                    clause.expr =
2751                        self.resolve_expr_subqueries(clause.expr.clone(), outer_scopes, frame)?;
2752                }
2753                Ok(Expr::WindowFunctionCall {
2754                    name,
2755                    args,
2756                    window,
2757                    span,
2758                })
2759            }
2760            Expr::Case {
2761                branches,
2762                else_,
2763                span,
2764            } => {
2765                let branches = branches
2766                    .into_iter()
2767                    .map(|(cond, value)| {
2768                        Ok((
2769                            self.resolve_expr_subqueries(cond, outer_scopes, frame)?,
2770                            self.resolve_expr_subqueries(value, outer_scopes, frame)?,
2771                        ))
2772                    })
2773                    .collect::<RedDBResult<Vec<_>>>()?;
2774                let else_ = else_
2775                    .map(|expr| self.resolve_expr_subqueries(*expr, outer_scopes, frame))
2776                    .transpose()?
2777                    .map(Box::new);
2778                Ok(Expr::Case {
2779                    branches,
2780                    else_,
2781                    span,
2782                })
2783            }
2784            Expr::IsNull {
2785                operand,
2786                negated,
2787                span,
2788            } => Ok(Expr::IsNull {
2789                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2790                negated,
2791                span,
2792            }),
2793            Expr::InList {
2794                target,
2795                values,
2796                negated,
2797                span,
2798            } => {
2799                let target =
2800                    Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?);
2801                let mut resolved = Vec::new();
2802                for value in values {
2803                    if let Expr::Subquery { query, .. } = value {
2804                        resolved.extend(
2805                            self.execute_expr_subquery_values(query, outer_scopes, frame)?
2806                                .into_iter()
2807                                .map(Expr::lit),
2808                        );
2809                    } else {
2810                        resolved.push(self.resolve_expr_subqueries(value, outer_scopes, frame)?);
2811                    }
2812                }
2813                Ok(Expr::InList {
2814                    target,
2815                    values: resolved,
2816                    negated,
2817                    span,
2818                })
2819            }
2820            Expr::Between {
2821                target,
2822                low,
2823                high,
2824                negated,
2825                span,
2826            } => Ok(Expr::Between {
2827                target: Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?),
2828                low: Box::new(self.resolve_expr_subqueries(*low, outer_scopes, frame)?),
2829                high: Box::new(self.resolve_expr_subqueries(*high, outer_scopes, frame)?),
2830                negated,
2831                span,
2832            }),
2833            other => Ok(other),
2834        }
2835    }
2836
2837    fn execute_expr_subquery_values(
2838        &self,
2839        subquery: reddb_rql::ast::ExprSubquery,
2840        outer_scopes: &[String],
2841        frame: &dyn super::statement_frame::ReadFrame,
2842    ) -> RedDBResult<Vec<Value>> {
2843        let query = *subquery.query;
2844        if query_references_outer_scope(&query, outer_scopes) {
2845            return Err(RedDBError::Query(
2846                "NOT_YET_SUPPORTED: correlated subqueries are not supported yet; track follow-up issue #470-correlated-subqueries".to_string(),
2847            ));
2848        }
2849        let query = self.rewrite_view_refs(query);
2850        let query = self.resolve_select_expr_subqueries(query, frame)?;
2851        let query = self.authorize_relational_select_expr(query, frame)?;
2852        let result = match query {
2853            QueryExpr::Table(table) => {
2854                execute_runtime_table_query(&self.inner.db, &table, Some(&self.inner.index_store))?
2855            }
2856            QueryExpr::Join(join) => execute_runtime_join_query(self, &join)?,
2857            other => {
2858                return Err(RedDBError::Query(format!(
2859                    "expression subquery must be a SELECT query, got {}",
2860                    query_expr_name(&other)
2861                )))
2862            }
2863        };
2864        first_column_values(result)
2865    }
2866
2867    fn execute_materialized_graph(
2868        &self,
2869        query: &str,
2870        mode: QueryMode,
2871        statement: &'static str,
2872        expr: &QueryExpr,
2873    ) -> RedDBResult<RuntimeQueryResult> {
2874        // Apply MVCC visibility + RLS gate while materialising the
2875        // graph: every node entity is screened against the source
2876        // collection's policy chain (basic and `Nodes`-targeted)
2877        // and dropped when the caller's tenant / role doesn't
2878        // admit it. Edges are pruned automatically because the
2879        // graph builder skips edges whose endpoints aren't in
2880        // `allowed_nodes`.
2881        let (graph, node_properties, edge_properties) = self.materialize_graph_with_rls()?;
2882        let result =
2883            crate::storage::query::unified::UnifiedExecutor::execute_on_with_graph_properties_checked(
2884                &graph,
2885                expr,
2886                node_properties,
2887                edge_properties,
2888                super::function_budget::graph_work_check(),
2889            )
2890            .map_err(|err| RedDBError::Query(err.to_string()))?;
2891
2892        Ok(RuntimeQueryResult {
2893            query: query.to_string(),
2894            mode,
2895            statement,
2896            engine: "materialized-graph",
2897            result,
2898            affected_rows: 0,
2899            statement_type: "select",
2900            bookmark: None,
2901            notice: None,
2902        })
2903    }
2904
2905    fn dispatch_expr(
2906        &self,
2907        expr: QueryExpr,
2908        query_str: &str,
2909        mode: QueryMode,
2910    ) -> RedDBResult<RuntimeQueryResult> {
2911        let statement = query_expr_name(&expr);
2912        match expr {
2913            QueryExpr::Function(command) => self.execute_function(query_str, &command),
2914            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
2915                self.execute_materialized_graph(query_str, mode, statement, &expr)
2916            }
2917            QueryExpr::Table(table) => {
2918                let scope = self.ai_scope();
2919                let table = self.resolve_table_expr_subqueries(
2920                    table,
2921                    &scope as &dyn super::statement_frame::ReadFrame,
2922                )?;
2923                // Table-valued functions (e.g. components(g)) dispatch to a
2924                // read-only executor before any catalog/virtual-table routing
2925                // (issue #795).
2926                if let Some(TableSource::Function {
2927                    name,
2928                    args,
2929                    named_args,
2930                }) = table.source.clone()
2931                {
2932                    return Ok(RuntimeQueryResult {
2933                        query: query_str.to_string(),
2934                        mode,
2935                        statement,
2936                        engine: "runtime-graph-tvf",
2937                        result: self.execute_table_function(&name, &args, &named_args)?,
2938                        affected_rows: 0,
2939                        statement_type: "select",
2940                        bookmark: None,
2941                        notice: None,
2942                    });
2943                }
2944                // Inline-graph TVF (issue #799) on the prepared-statement /
2945                // direct-expr path. Result caching is wired on the
2946                // `execute_query_inner` path; here we just compute and return.
2947                if let Some(TableSource::InlineGraphFunction {
2948                    name,
2949                    nodes,
2950                    edges,
2951                    named_args,
2952                }) = table.source.clone()
2953                {
2954                    return Ok(RuntimeQueryResult {
2955                        query: query_str.to_string(),
2956                        mode,
2957                        statement,
2958                        engine: "runtime-graph-tvf-inline",
2959                        result: self.execute_inline_graph_function(
2960                            &name,
2961                            &nodes,
2962                            &edges,
2963                            &named_args,
2964                        )?,
2965                        affected_rows: 0,
2966                        statement_type: "select",
2967                        bookmark: None,
2968                        notice: None,
2969                    });
2970                }
2971                if super::red_schema::is_virtual_table(&table.table) {
2972                    return Ok(RuntimeQueryResult {
2973                        query: query_str.to_string(),
2974                        mode,
2975                        statement,
2976                        engine: "runtime-red-schema",
2977                        result: super::red_schema::red_query(
2978                            self,
2979                            &table.table,
2980                            &table,
2981                            &scope as &dyn super::statement_frame::ReadFrame,
2982                        )?,
2983                        affected_rows: 0,
2984                        statement_type: "select",
2985                        bookmark: None,
2986                        notice: None,
2987                    });
2988                }
2989                // `<graph>.<output>` analytics virtual view (issue #800).
2990                if let Some(view_result) = self.try_resolve_analytics_view(
2991                    &table,
2992                    &scope as &dyn super::statement_frame::ReadFrame,
2993                )? {
2994                    return Ok(RuntimeQueryResult {
2995                        query: query_str.to_string(),
2996                        mode,
2997                        statement,
2998                        engine: "runtime-graph-analytics-view",
2999                        result: view_result,
3000                        affected_rows: 0,
3001                        statement_type: "select",
3002                        bookmark: None,
3003                        notice: None,
3004                    });
3005                }
3006                let Some(table_with_rls) = self.authorize_relational_table_select(
3007                    table,
3008                    &scope as &dyn super::statement_frame::ReadFrame,
3009                )?
3010                else {
3011                    return Ok(RuntimeQueryResult {
3012                        query: query_str.to_string(),
3013                        mode,
3014                        statement,
3015                        engine: "runtime-table-rls",
3016                        result: crate::storage::query::unified::UnifiedResult::empty(),
3017                        affected_rows: 0,
3018                        statement_type: "select",
3019                        bookmark: None,
3020                        notice: None,
3021                    });
3022                };
3023                Ok(RuntimeQueryResult {
3024                    query: query_str.to_string(),
3025                    mode,
3026                    statement,
3027                    engine: "runtime-table",
3028                    result: execute_runtime_table_query(
3029                        &self.inner.db,
3030                        &table_with_rls,
3031                        Some(&self.inner.index_store),
3032                    )?,
3033                    affected_rows: 0,
3034                    statement_type: "select",
3035                    bookmark: None,
3036                    notice: None,
3037                })
3038            }
3039            QueryExpr::Join(join) => {
3040                let scope = self.ai_scope();
3041                let Some(join_with_rls) = self.authorize_relational_join_select(
3042                    join,
3043                    &scope as &dyn super::statement_frame::ReadFrame,
3044                )?
3045                else {
3046                    return Ok(RuntimeQueryResult {
3047                        query: query_str.to_string(),
3048                        mode,
3049                        statement,
3050                        engine: "runtime-join-rls",
3051                        result: crate::storage::query::unified::UnifiedResult::empty(),
3052                        affected_rows: 0,
3053                        statement_type: "select",
3054                        bookmark: None,
3055                        notice: None,
3056                    });
3057                };
3058                Ok(RuntimeQueryResult {
3059                    query: query_str.to_string(),
3060                    mode,
3061                    statement,
3062                    engine: "runtime-join",
3063                    result: execute_runtime_join_query(self, &join_with_rls)?,
3064                    affected_rows: 0,
3065                    statement_type: "select",
3066                    bookmark: None,
3067                    notice: None,
3068                })
3069            }
3070            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
3071                query: query_str.to_string(),
3072                mode,
3073                statement,
3074                engine: "runtime-vector",
3075                result: execute_runtime_vector_query(self, &vector)?,
3076                affected_rows: 0,
3077                statement_type: "select",
3078                bookmark: None,
3079                notice: None,
3080            }),
3081            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
3082                query: query_str.to_string(),
3083                mode,
3084                statement,
3085                engine: "runtime-hybrid",
3086                result: execute_runtime_hybrid_query(self, &hybrid)?,
3087                affected_rows: 0,
3088                statement_type: "select",
3089                bookmark: None,
3090                notice: None,
3091            }),
3092            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
3093                Err(RedDBError::Query(
3094                    super::red_schema::READ_ONLY_ERROR.to_string(),
3095                ))
3096            }
3097            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
3098                Err(RedDBError::Query(
3099                    super::red_schema::READ_ONLY_ERROR.to_string(),
3100                ))
3101            }
3102            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
3103                Err(RedDBError::Query(
3104                    super::red_schema::READ_ONLY_ERROR.to_string(),
3105                ))
3106            }
3107            QueryExpr::Insert(ref insert) => self
3108                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
3109                    self.execute_insert(query_str, insert)
3110                }),
3111            QueryExpr::Update(ref update) => self
3112                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
3113                    self.execute_update(query_str, update)
3114                }),
3115            QueryExpr::Delete(ref delete) => self
3116                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
3117                    self.execute_delete(query_str, delete)
3118                }),
3119            QueryExpr::QueueCommand(ref cmd) => self.with_deferred_store_wal_if_transaction(|| {
3120                self.execute_queue_command(query_str, cmd)
3121            }),
3122            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query_str, cmd),
3123            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query_str, cmd),
3124            QueryExpr::Ask(ref ask) => self.execute_ask(query_str, ask),
3125            _ => Err(RedDBError::Query(format!(
3126                "prepared-statement execution does not support {statement} statements"
3127            ))),
3128        }
3129    }
3130
3131    /// Dispatch a graph-collection table-valued function call in FROM
3132    /// position (e.g. `SELECT * FROM components(g)`).
3133    ///
3134    /// Validates the function name and arity here, materializes the whole
3135    /// active graph read-only, then runs the algorithm via the shared
3136    /// `dispatch_graph_algorithm` path. Never mutates the catalog or store.
3137    fn execute_table_function(
3138        &self,
3139        name: &str,
3140        args: &[String],
3141        named_args: &[(String, f64)],
3142    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
3143        if name.eq_ignore_ascii_case("red.diff") {
3144            return self.execute_vcs_diff_tvf(args, named_args);
3145        }
3146        if !is_graph_tvf_name(name) {
3147            return Err(RedDBError::Query(format!("unknown table function: {name}")));
3148        }
3149        // Every graph-collection TVF takes exactly one graph argument.
3150        if args.len() != 1 {
3151            return Err(RedDBError::Query(format!(
3152                "table function '{name}' takes exactly 1 graph argument, got {}",
3153                args.len()
3154            )));
3155        }
3156
3157        // Read-only materialization of the full active graph. Passing `None`
3158        // for the projection uses the full graph store. Like #795/#796, the
3159        // v0 form runs over the whole graph store regardless of the collection
3160        // argument value. Materialization never mutates any store.
3161        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
3162        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
3163    }
3164
3165    pub(crate) fn revive_versioned_updates_since(
3166        &self,
3167        conn_id: u64,
3168        stamper_xid: u64,
3169    ) -> RedDBResult<usize> {
3170        // Snapshot undo identities without retaining the journal while waiting
3171        // for topology. Writers take topology before recording their undo entry.
3172        let candidates = {
3173            let guard = self.inner.pending_versioned_updates.read();
3174            let Some(pending) = guard.get(&conn_id) else {
3175                return Ok(0);
3176            };
3177            pending
3178                .iter()
3179                .rev()
3180                .filter(|entry| entry.3 >= stamper_xid)
3181                .cloned()
3182                .collect::<Vec<_>>()
3183        };
3184        let mut reverted = 0usize;
3185        for candidate in candidates {
3186            let (collection, old_id, new_id, xid, previous_xmax) = &candidate;
3187            let topology_lock = self.index_store_ref().collection_topology_lock(collection);
3188            let topology_guard = topology_lock.read();
3189            let mut guard = self.inner.pending_versioned_updates.write();
3190            let Some(pending) = guard.get_mut(&conn_id) else {
3191                continue;
3192            };
3193            // Revalidate after waiting. Remove only successfully restored entries;
3194            // an error leaves this entry and the remaining undo journal intact.
3195            let Some(position) = pending.iter().rposition(|entry| entry == &candidate) else {
3196                continue;
3197            };
3198            self.revive_versioned_update(
3199                collection,
3200                *old_id,
3201                *new_id,
3202                *xid,
3203                *previous_xmax,
3204                topology_guard,
3205            )?;
3206            pending.remove(position);
3207            reverted += 1;
3208            if pending.is_empty() {
3209                guard.remove(&conn_id);
3210            }
3211        }
3212        Ok(reverted)
3213    }
3214
3215    /// `EXPLAIN` is peeled off the statement text before parsing, so the
3216    /// planner ran on the inner statement with no privilege check at all:
3217    /// a read-only principal could plan any write and read the table's
3218    /// column list and row estimate back out of the plan. The plan needs
3219    /// exactly the privilege the statement it describes needs.
3220    fn check_explain_target_privilege(&self, inner_sql: &str) -> RedDBResult<()> {
3221        let parsed =
3222            reddb_rql::parser::parse(inner_sql).map_err(|e| RedDBError::Query(e.to_string()))?;
3223        self.check_query_privilege(&parsed.query)
3224            .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))
3225    }
3226
3227    /// Wrap the planner's `RuntimeQueryExplain` as rows on a
3228    /// `RuntimeQueryResult` so callers over the SQL interface see the
3229    /// plan tree in the same shape a SELECT produces.
3230    ///
3231    /// Columns: `op`, `source`, `estimated_rows`, `estimated_cost`, `depth`.
3232    /// Nodes are walked depth-first; `depth` counts from 0 at the
3233    /// root so a text renderer can indent without re-walking.
3234    fn explain_as_rows(&self, raw_query: &str, inner_sql: &str) -> RedDBResult<RuntimeQueryResult> {
3235        self.check_explain_target_privilege(inner_sql)?;
3236        let explain = self.explain_query(inner_sql)?;
3237
3238        let mut columns = vec![
3239            "op".to_string(),
3240            "source".to_string(),
3241            "estimated_rows".to_string(),
3242            "estimated_cost".to_string(),
3243            "depth".to_string(),
3244        ];
3245
3246        let mut records: Vec<crate::storage::query::unified::UnifiedRecord> = Vec::new();
3247
3248        // Prepend `CteScan` markers when the query carried a leading
3249        // WITH clause. The CTE bodies are already inlined into the
3250        // main plan tree, but operators reading EXPLAIN need to see
3251        // which named CTEs were resolved — without this row the plan
3252        // would look indistinguishable from a hand-inlined query.
3253        for name in &explain.cte_materializations {
3254            use std::sync::Arc;
3255            let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3256            rec.set_arc(Arc::from("op"), Value::text("CteScan".to_string()));
3257            rec.set_arc(Arc::from("source"), Value::text(name.clone()));
3258            rec.set_arc(Arc::from("estimated_rows"), Value::Float(0.0));
3259            rec.set_arc(Arc::from("estimated_cost"), Value::Float(0.0));
3260            rec.set_arc(Arc::from("depth"), Value::Integer(0));
3261            records.push(rec);
3262        }
3263
3264        let vector_details = explain.statement == "vector";
3265        if vector_details {
3266            columns.extend(["access_path_reason".to_string(), "index_hint".to_string()]);
3267        }
3268        walk_plan_node(&explain.logical_plan.root, 0, &mut records, vector_details);
3269
3270        let result = crate::storage::query::unified::UnifiedResult {
3271            columns,
3272            records,
3273            stats: Default::default(),
3274            pre_serialized_json: None,
3275        };
3276
3277        Ok(RuntimeQueryResult {
3278            query: raw_query.to_string(),
3279            mode: explain.mode,
3280            statement: "explain",
3281            engine: "runtime-explain",
3282            result,
3283            affected_rows: 0,
3284            statement_type: "select",
3285            bookmark: None,
3286            notice: None,
3287        })
3288    }
3289
3290    fn explain_analyze_vector_as_rows(
3291        &self,
3292        raw_query: &str,
3293        expr: QueryExpr,
3294    ) -> RedDBResult<RuntimeQueryResult> {
3295        let QueryExpr::Vector(query) = &expr else {
3296            return Err(RedDBError::Internal(
3297                "expected vector analysis target".to_string(),
3298            ));
3299        };
3300        let source = query.collection.clone();
3301        // The typed entry installs the full statement frame and privilege gate,
3302        // and cannot serve an earlier result-cache measurement as a fresh run.
3303        let execution = self.execute_query_expr(expr)?;
3304        let stats = execution.result.stats;
3305        let vector = stats.vector.as_ref().ok_or_else(|| {
3306            RedDBError::Internal("vector executor did not provide measurements".to_string())
3307        })?;
3308        let fields = vec![
3309            ("op", Value::text(vector.access_path.clone())),
3310            ("source", Value::text(source)),
3311            ("index_used", Value::Boolean(vector.index_used)),
3312            ("mode_requested", Value::text(vector.mode_requested.clone())),
3313            ("mode_executed", Value::text(vector.mode_executed.clone())),
3314            (
3315                "fallback_reason",
3316                vector
3317                    .fallback_reason
3318                    .as_ref()
3319                    .map_or(Value::Null, |reason| Value::text(reason.clone())),
3320            ),
3321            (
3322                "approximate_distance_evaluations",
3323                Value::UnsignedInteger(vector.approximate_distance_evaluations),
3324            ),
3325            (
3326                "peak_topk_entries",
3327                Value::UnsignedInteger(vector.peak_topk_entries),
3328            ),
3329            (
3330                "candidates_examined",
3331                Value::UnsignedInteger(vector.candidates_examined),
3332            ),
3333            (
3334                "metadata_rejected",
3335                Value::UnsignedInteger(vector.metadata_rejected),
3336            ),
3337            ("rls_rejected", Value::UnsignedInteger(vector.rls_rejected)),
3338            (
3339                "visibility_rejected",
3340                Value::UnsignedInteger(vector.visibility_rejected),
3341            ),
3342            (
3343                "exact_distance_evaluations",
3344                Value::UnsignedInteger(vector.exact_distance_evaluations),
3345            ),
3346            ("actual_rows", Value::UnsignedInteger(vector.rows_returned)),
3347            (
3348                "actual_ms",
3349                Value::Float(stats.exec_time_us as f64 / 1000.0),
3350            ),
3351            ("metrics_scope", Value::text("vector_pipeline")),
3352            (
3353                "operators",
3354                Value::Json(vector.operators_json().to_string().into_bytes()),
3355            ),
3356        ];
3357        let columns = fields.iter().map(|(name, _)| name.to_string()).collect();
3358        let mut record = crate::storage::query::unified::UnifiedRecord::default();
3359        for (name, value) in fields {
3360            record.set_owned(name.to_string(), value);
3361        }
3362        Ok(RuntimeQueryResult {
3363            query: raw_query.to_string(),
3364            mode: execution.mode,
3365            statement: "explain_analyze",
3366            engine: "runtime-explain-analyze",
3367            result: crate::storage::query::unified::UnifiedResult {
3368                columns,
3369                records: vec![record],
3370                stats,
3371                pre_serialized_json: None,
3372            },
3373            affected_rows: 0,
3374            statement_type: "select",
3375            bookmark: None,
3376            notice: None,
3377        })
3378    }
3379
3380    fn explain_analyze_as_rows(
3381        &self,
3382        raw_query: &str,
3383        inner_sql: &str,
3384    ) -> RedDBResult<RuntimeQueryResult> {
3385        if strip_keyword_ci(inner_sql.trim_start(), "VECTOR").is_some() {
3386            let expr = parse_multi(inner_sql).map_err(|err| RedDBError::Query(err.to_string()))?;
3387            if matches!(&expr, QueryExpr::Vector(_)) {
3388                return self.explain_analyze_vector_as_rows(raw_query, expr);
3389            }
3390        }
3391        if !starts_with_dml_keyword(inner_sql) {
3392            return Err(RedDBError::Query(
3393                "EXPLAIN ANALYZE currently supports INSERT, UPDATE, DELETE, and VECTOR SEARCH"
3394                    .to_string(),
3395            ));
3396        }
3397
3398        self.check_explain_target_privilege(inner_sql)?;
3399        let explain = self.explain_query(inner_sql)?;
3400        let conn_id = current_connection_id();
3401        if self.inner.transaction_state.in_transaction(conn_id) {
3402            return Err(RedDBError::Query(
3403                "EXPLAIN ANALYZE requires no active transaction".to_string(),
3404            ));
3405        }
3406
3407        self.execute_query_inner("BEGIN")?;
3408        let started = std::time::Instant::now();
3409        let execution = self.execute_query_inner(inner_sql);
3410        let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
3411        let rollback = self.execute_query_inner("ROLLBACK");
3412
3413        let affected_rows = match execution {
3414            Ok(result) => result.affected_rows,
3415            Err(err) => {
3416                rollback?;
3417                return Err(err);
3418            }
3419        };
3420        rollback?;
3421
3422        let columns = vec![
3423            "op".to_string(),
3424            "source".to_string(),
3425            "estimated_rows".to_string(),
3426            "estimated_cost".to_string(),
3427            "actual_rows".to_string(),
3428            "actual_ms".to_string(),
3429            "depth".to_string(),
3430        ];
3431        let mut records = Vec::new();
3432        walk_analyze_plan_node(
3433            &explain.logical_plan.root,
3434            0,
3435            affected_rows,
3436            elapsed_ms,
3437            &mut records,
3438        );
3439
3440        let result = crate::storage::query::unified::UnifiedResult {
3441            columns,
3442            records,
3443            stats: Default::default(),
3444            pre_serialized_json: None,
3445        };
3446
3447        Ok(RuntimeQueryResult {
3448            query: raw_query.to_string(),
3449            mode: explain.mode,
3450            statement: "explain_analyze",
3451            engine: "runtime-explain-analyze",
3452            result,
3453            affected_rows: 0,
3454            statement_type: "select",
3455            bookmark: None,
3456            notice: None,
3457        })
3458    }
3459}
3460
3461fn strip_explain_analyze_prefix(sql: &str) -> Option<&str> {
3462    let rest = strip_keyword_ci(sql.trim_start(), "EXPLAIN")?.trim_start();
3463    Some(strip_keyword_ci(rest, "ANALYZE")?.trim_start()).filter(|inner| !inner.is_empty())
3464}
3465
3466fn strip_keyword_ci<'a>(sql: &'a str, keyword: &str) -> Option<&'a str> {
3467    // `get` is None past the end or inside a multi-byte character, where
3468    // `split_at` would panic (and a release build aborts on panic).
3469    let head = sql.get(..keyword.len())?;
3470    let rest = &sql[keyword.len()..];
3471    if !head.eq_ignore_ascii_case(keyword) {
3472        return None;
3473    }
3474    if rest
3475        .chars()
3476        .next()
3477        .is_some_and(|ch| !ch.is_ascii_whitespace())
3478    {
3479        return None;
3480    }
3481    Some(rest)
3482}
3483
3484fn starts_with_dml_keyword(sql: &str) -> bool {
3485    let trimmed = sql.trim_start();
3486    let head_end = trimmed
3487        .find(|ch: char| ch.is_ascii_whitespace())
3488        .unwrap_or(trimmed.len());
3489    let head = &trimmed[..head_end];
3490    head.eq_ignore_ascii_case("INSERT")
3491        || head.eq_ignore_ascii_case("UPDATE")
3492        || head.eq_ignore_ascii_case("DELETE")
3493}
3494
3495fn walk_plan_node(
3496    node: &crate::storage::query::planner::CanonicalLogicalNode,
3497    depth: usize,
3498    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3499    vector_details: bool,
3500) {
3501    use std::sync::Arc;
3502    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3503    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3504    rec.set_arc(
3505        Arc::from("source"),
3506        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3507    );
3508    rec.set_arc(
3509        Arc::from("estimated_rows"),
3510        Value::Float(node.estimated_rows),
3511    );
3512    rec.set_arc(
3513        Arc::from("estimated_cost"),
3514        Value::Float(node.operator_cost),
3515    );
3516    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3517    if vector_details {
3518        for name in ["access_path_reason", "index_hint"] {
3519            rec.set_owned(
3520                name.to_string(),
3521                node.details
3522                    .get(name)
3523                    .cloned()
3524                    .map(Value::text)
3525                    .unwrap_or(Value::Null),
3526            );
3527        }
3528    }
3529    out.push(rec);
3530    for child in &node.children {
3531        walk_plan_node(child, depth + 1, out, vector_details);
3532    }
3533}
3534
3535fn walk_analyze_plan_node(
3536    node: &crate::storage::query::planner::CanonicalLogicalNode,
3537    depth: usize,
3538    affected_rows: u64,
3539    elapsed_ms: f64,
3540    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3541) {
3542    use std::sync::Arc;
3543    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3544    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3545    rec.set_arc(
3546        Arc::from("source"),
3547        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3548    );
3549    rec.set_arc(
3550        Arc::from("estimated_rows"),
3551        Value::Float(node.estimated_rows),
3552    );
3553    rec.set_arc(
3554        Arc::from("estimated_cost"),
3555        Value::Float(node.operator_cost),
3556    );
3557    rec.set_arc(
3558        Arc::from("actual_rows"),
3559        Value::UnsignedInteger(if depth == 0 { affected_rows } else { 0 }),
3560    );
3561    rec.set_arc(
3562        Arc::from("actual_ms"),
3563        Value::Float(if depth == 0 { elapsed_ms } else { 0.0 }),
3564    );
3565    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3566    out.push(rec);
3567
3568    for child in &node.children {
3569        walk_analyze_plan_node(child, depth + 1, 0, 0.0, out);
3570    }
3571}
3572
3573#[cfg(test)]
3574mod multibyte_sql_tests {
3575    use super::*;
3576
3577    #[test]
3578    fn explain_analyze_prefix_ignores_multibyte_text_at_the_keyword_boundary() {
3579        // `strip_keyword_ci` used `split_at(keyword.len())`, which panics when
3580        // that byte offset is inside a multi-byte character.
3581        for pad in 0..16 {
3582            for wide in ["é", "日", "😀"] {
3583                let sql = format!("{}{wide}llo", "a".repeat(pad));
3584                assert_eq!(strip_explain_analyze_prefix(&sql), None, "{sql:?}");
3585            }
3586        }
3587        assert_eq!(
3588            strip_explain_analyze_prefix("explain  analyze DELETE FROM t"),
3589            Some("DELETE FROM t")
3590        );
3591    }
3592
3593    /// One malformed or unusual statement must never take the process down:
3594    /// release builds abort on panic, so any panic reachable from
3595    /// `execute_query` is a remote denial of service. Put a multi-byte
3596    /// character at every byte offset a keyword matcher might slice at.
3597    #[test]
3598    fn multibyte_sql_never_panics_execute_query() {
3599        let rt = RedDBRuntime::in_memory().expect("in-memory runtime");
3600        let seeds = [
3601            "",
3602            "SELECT ",
3603            "SELECT '",
3604            "EXPLAIN ",
3605            "EXPLAIN ANALYZE ",
3606            "CHECKPOINT ",
3607            "CHECKOUT ",
3608            "RESET ",
3609            "MERGE ",
3610            "CHERRY PICK ",
3611            "REVERT ",
3612            "RESOLVE CONFLICT ",
3613            "INSERT INTO t VALUES ('",
3614        ];
3615        let mut panicked = Vec::new();
3616        for seed in seeds {
3617            for pad in 0..16 {
3618                for wide in ["é", "日", "😀"] {
3619                    let sql = format!("{seed}{}{wide}llo", "a".repeat(pad));
3620                    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3621                        let _ = rt.execute_query(&sql);
3622                    }));
3623                    if outcome.is_err() {
3624                        panicked.push(sql);
3625                    }
3626                }
3627            }
3628        }
3629        assert!(
3630            panicked.is_empty(),
3631            "execute_query panicked on {} statement(s), first: {:?}",
3632            panicked.len(),
3633            panicked.first()
3634        );
3635    }
3636}
3637
3638#[cfg(test)]
3639mod inline_graph_tvf_tests {
3640    use super::*;
3641
3642    fn scopes_for(sql: &str) -> HashSet<String> {
3643        let expr = reddb_rql::parser::parse(sql).expect("parse").query;
3644        query_expr_result_cache_scopes(&expr)
3645    }
3646
3647    #[test]
3648    fn inline_tvf_cache_scopes_include_source_collections() {
3649        // The result-cache key for the inline form must derive from the
3650        // `nodes`/`edges` source collections so a write to either invalidates
3651        // the cached result (issue #799).
3652        let scopes = scopes_for(
3653            "SELECT * FROM components(nodes => (SELECT id FROM hosts), edges => (SELECT src, dst FROM links))",
3654        );
3655        assert!(scopes.contains("hosts"), "nodes source scoped: {scopes:?}");
3656        assert!(scopes.contains("links"), "edges source scoped: {scopes:?}");
3657    }
3658
3659    #[test]
3660    fn graph_collection_tvf_cache_scope_is_graph_argument() {
3661        // The graph-collection form still materializes the active graph, but
3662        // result-cache invalidation is scoped to the named graph argument so
3663        // INSERT INTO g NODE/EDGE invalidates cached TVF rows.
3664        let scopes = scopes_for("SELECT * FROM components(g)");
3665        assert!(scopes.contains("g"), "collection form scoped: {scopes:?}");
3666    }
3667
3668    #[test]
3669    fn abstract_degree_centrality_counts_undirected_endpoints() {
3670        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
3671        let edges = vec![
3672            ("a".to_string(), "b".to_string(), 1.0_f32),
3673            ("b".to_string(), "c".to_string(), 1.0_f32),
3674        ];
3675        let degrees = abstract_degree_centrality(&nodes, &edges);
3676        assert_eq!(
3677            degrees,
3678            vec![
3679                ("a".to_string(), 1),
3680                ("b".to_string(), 2),
3681                ("c".to_string(), 1),
3682            ]
3683        );
3684    }
3685}
3686
3687/// Vault keys the engine itself owns. `SET SECRET` / `DELETE SECRET` share
3688/// the vault KV space with the IAM policy blobs, the attachment tables and
3689/// the `SECRET()` column master key; letting a principal with `secret:write`
3690/// overwrite them is an escalation (allow-all policy after restart) or a
3691/// permanent data loss (rotated AES key). Provider credentials under
3692/// `red.secret.ai.*` stay writable — that is the documented use.
3693fn reject_engine_internal_vault_key(key: &str) -> RedDBResult<()> {
3694    let lowered = key.to_ascii_lowercase();
3695    let internal = lowered == "red.secret.aes_key"
3696        || lowered.starts_with("red.iam.")
3697        || lowered.starts_with("red.acl.")
3698        || lowered.starts_with("red.vault.");
3699    if internal {
3700        return Err(RedDBError::Query(format!(
3701            "`{key}` is an engine-internal vault key and cannot be set or deleted through SET/DELETE SECRET"
3702        )));
3703    }
3704    Ok(())
3705}
3706
3707/// Optional confinement for `COPY … FROM '<path>'`. When
3708/// `REDDB_COPY_IMPORT_ROOT` is set, the (canonicalised) source path must live
3709/// under that directory; otherwise the statement is refused. Unset keeps the
3710/// historical behaviour (any readable file, admin-only via the privilege
3711/// gate).
3712fn confine_copy_from_path(path: &str) -> RedDBResult<()> {
3713    let Ok(root) = std::env::var("REDDB_COPY_IMPORT_ROOT") else {
3714        return Ok(());
3715    };
3716    let root = std::fs::canonicalize(&root).map_err(|e| {
3717        RedDBError::Query(format!(
3718            "REDDB_COPY_IMPORT_ROOT `{root}` is not usable: {e}"
3719        ))
3720    })?;
3721    let resolved = std::fs::canonicalize(path)
3722        .map_err(|e| RedDBError::Query(format!("COPY FROM `{path}`: {e}")))?;
3723    if !resolved.starts_with(&root) || !resolved.is_file() {
3724        return Err(RedDBError::Query(format!(
3725            "COPY FROM `{path}` is outside the configured import root"
3726        )));
3727    }
3728    Ok(())
3729}
3730
3731#[cfg(test)]
3732mod security_gate_tests {
3733    use super::*;
3734    use crate::auth::store::AuthStore;
3735    use crate::auth::{AuthConfig, Role};
3736
3737    /// Clears the thread-local identity on drop so one test's principal
3738    /// never leaks into the next on a shared thread.
3739    struct IdentityGuard;
3740
3741    impl IdentityGuard {
3742        fn install(username: &str, role: Role) -> Self {
3743            set_current_auth_identity(username.to_string(), role);
3744            Self
3745        }
3746    }
3747
3748    impl Drop for IdentityGuard {
3749        fn drop(&mut self) {
3750            clear_current_auth_identity();
3751        }
3752    }
3753
3754    fn runtime_with_user(username: &str, role: Role) -> RedDBRuntime {
3755        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
3756            enabled: true,
3757            session_ttl_secs: 60,
3758            require_auth: true,
3759            auto_encrypt_storage: false,
3760            vault_enabled: false,
3761            cert: Default::default(),
3762            oauth: Default::default(),
3763        }));
3764        store.create_user(username, "pw", role).expect("user");
3765        let runtime =
3766            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
3767        runtime.set_auth_store(store);
3768        runtime
3769    }
3770
3771    fn runtime_with_store(username: &str, role: Role) -> (RedDBRuntime, std::sync::Arc<AuthStore>) {
3772        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
3773            enabled: true,
3774            session_ttl_secs: 60,
3775            require_auth: true,
3776            auto_encrypt_storage: false,
3777            vault_enabled: false,
3778            cert: Default::default(),
3779            oauth: Default::default(),
3780        }));
3781        store.create_user(username, "pw", role).expect("user");
3782        let runtime =
3783            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
3784        runtime.set_auth_store(std::sync::Arc::clone(&store));
3785        (runtime, store)
3786    }
3787
3788    #[test]
3789    fn nested_reads_need_select_on_the_inner_table() {
3790        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
3791        use crate::auth::UserId;
3792        let (runtime, store) = runtime_with_store("ro", Role::Read);
3793        runtime
3794            .execute_query("CREATE TABLE gate_open (a INTEGER)")
3795            .expect("create");
3796        runtime
3797            .execute_query("CREATE TABLE gate_closed (b INTEGER)")
3798            .expect("create");
3799        // One explicit grant switches the legacy fallback off, so `ro`
3800        // may read exactly `gate_open`.
3801        store
3802            .grant(
3803                &UserId::from_parts(None, "admin"),
3804                Role::Admin,
3805                GrantPrincipal::User(UserId::from_parts(None, "ro")),
3806                Resource::table_from_name("gate_open"),
3807                vec![Action::Select],
3808                false,
3809                None,
3810            )
3811            .expect("grant");
3812        let _identity = IdentityGuard::install("ro", Role::Read);
3813        runtime
3814            .execute_query("SELECT * FROM (SELECT a FROM gate_open) s")
3815            .expect("derived table over a granted table is fine");
3816        // The outer statement names only the synthetic subquery table, so
3817        // the inner table used to be read with no check at all.
3818        for sql in [
3819            "SELECT * FROM (SELECT b FROM gate_closed) s",
3820            "SELECT a FROM gate_open WHERE a IN (SELECT b FROM gate_closed)",
3821        ] {
3822            let err = runtime
3823                .execute_query(sql)
3824                .expect_err("nested read of an ungranted table must be refused");
3825            assert!(
3826                err.to_string().contains("permission denied"),
3827                "{sql}: unexpected error: {err}"
3828            );
3829        }
3830    }
3831
3832    #[test]
3833    fn policy_listings_are_self_or_policy_management() {
3834        let (runtime, store) = runtime_with_store("ro", Role::Read);
3835        store.create_user("other", "pw", Role::Read).expect("user");
3836        let _identity = IdentityGuard::install("ro", Role::Read);
3837        runtime
3838            .execute_query("SHOW EFFECTIVE PERMISSIONS FOR ro")
3839            .expect("a principal may inspect its own permissions");
3840        let err = runtime
3841            .execute_query("SHOW EFFECTIVE PERMISSIONS FOR other")
3842            .expect_err("inspecting another principal is policy management");
3843        assert!(
3844            err.to_string().contains("permission denied"),
3845            "unexpected error: {err}"
3846        );
3847    }
3848
3849    #[test]
3850    fn secret_columns_stay_masked_for_readers_without_secret_read() {
3851        use reddb_types::Value;
3852        let (runtime, store) = runtime_with_store("ro", Role::Read);
3853        store.create_user("root", "pw", Role::Admin).expect("admin");
3854        store.ensure_vault_secret_key();
3855        runtime
3856            .execute_query(
3857                "INSERT INTO creds_gate (name, token) VALUES ('stripe', SECRET('sk_live_abc'))",
3858            )
3859            .expect("insert");
3860
3861        let token_for = |username: &str, role: Role| {
3862            let _identity = IdentityGuard::install(username, role);
3863            let result = runtime
3864                .execute_query("SELECT name, token FROM creds_gate")
3865                .expect("select");
3866            result.result.records[0]
3867                .get("token")
3868                .cloned()
3869                .expect("token column")
3870        };
3871        // Ordinary SELECT stays masked for every role. Plaintext retrieval
3872        // requires an explicit reveal operation.
3873        assert!(
3874            matches!(token_for("ro", Role::Read), Value::Secret(_)),
3875            "a read-role principal must see the column masked"
3876        );
3877        assert!(matches!(token_for("root", Role::Admin), Value::Secret(_)));
3878    }
3879
3880    #[test]
3881    fn store_wide_operations_need_the_admin_tier() {
3882        let runtime = runtime_with_user("bob", Role::Write);
3883        let _identity = IdentityGuard::install("bob", Role::Write);
3884        // These fell through the gate's wildcard arm, so a write-role
3885        // principal could scrub or vacuum the store.
3886        for sql in ["VACUUM", "SCRUB"] {
3887            let err = runtime
3888                .execute_query(sql)
3889                .expect_err("store-wide operation must be refused for a non-admin");
3890            assert!(
3891                err.to_string().contains("permission denied"),
3892                "{sql}: unexpected error: {err}"
3893            );
3894        }
3895    }
3896
3897    #[test]
3898    fn explain_needs_the_privilege_of_the_statement_it_plans() {
3899        let runtime = runtime_with_user("ro", Role::Read);
3900        let _identity = IdentityGuard::install("ro", Role::Read);
3901        runtime
3902            .execute_query("EXPLAIN SELECT 1 AS one")
3903            .expect("planning a read is a read");
3904        // EXPLAIN used to bypass the gate entirely, so a read-only
3905        // principal could plan any write and read the schema back.
3906        let err = runtime
3907            .execute_query("EXPLAIN INSERT INTO gate_t (a) VALUES (1)")
3908            .expect_err("planning a write needs the write privilege");
3909        assert!(
3910            err.to_string().contains("permission denied"),
3911            "unexpected EXPLAIN error: {err}"
3912        );
3913    }
3914
3915    #[test]
3916    fn copy_from_a_server_path_is_admin_only() {
3917        let runtime = runtime_with_user("bob", Role::Write);
3918        let _identity = IdentityGuard::install("bob", Role::Write);
3919        // `COPY … FROM '<path>'` opens a file on the *server*, so a
3920        // write-role principal used to be able to read /proc/self/environ
3921        // (admin token, vault certificate, cloud keys) into a table.
3922        let err = runtime
3923            .execute_query("COPY leak FROM '/etc/hostname'")
3924            .expect_err("COPY FROM must be refused for a non-admin");
3925        assert!(
3926            err.to_string().contains("permission denied"),
3927            "unexpected COPY error: {err}"
3928        );
3929    }
3930
3931    #[test]
3932    fn vcs_history_rewrites_are_admin_only() {
3933        let runtime = runtime_with_user("bob", Role::Write);
3934        let _identity = IdentityGuard::install("bob", Role::Write);
3935        // VCS commands were dispatched before the statement frame was built,
3936        // so `RESET HARD TO` ran with no privilege check at all.
3937        let err = runtime
3938            .execute_query("RESET HARD TO 'deadbeef'")
3939            .expect_err("RESET must be refused for a non-admin");
3940        assert!(
3941            err.to_string().contains("permission denied"),
3942            "unexpected RESET error: {err}"
3943        );
3944    }
3945
3946    #[test]
3947    fn ai_provider_config_writes_are_admin_only() {
3948        let runtime = runtime_with_user("bob", Role::Write);
3949        let _identity = IdentityGuard::install("bob", Role::Write);
3950        // This namespace decides which vault secret is sent, as a bearer, to
3951        // which host — the exfiltration primitive behind the SET CONFIG gap.
3952        let err = runtime
3953            .execute_query(
3954                "SET CONFIG red.config.ai.providers.openai.tokens.default.secret_ref = 'red.secret.aes_key'",
3955            )
3956            .expect_err("AI provider config must be refused for a non-admin");
3957        assert!(
3958            err.to_string().contains("permission denied"),
3959            "unexpected SET CONFIG error: {err}"
3960        );
3961    }
3962
3963    #[test]
3964    fn engine_internal_vault_keys_are_not_writable_through_set_secret() {
3965        let runtime = runtime_with_user("root", Role::Admin);
3966        let _identity = IdentityGuard::install("root", Role::Admin);
3967        // Even an admin must not overwrite the IAM policy blob (an allow-all
3968        // policy would survive a restart) or the `SECRET()` master key
3969        // (rotating it destroys every encrypted column value).
3970        for key in [
3971            "red.iam.policies",
3972            "red.iam.attachments.users",
3973            "red.secret.aes_key",
3974        ] {
3975            let err = runtime
3976                .execute_query(&format!("SET SECRET {key} = 'x'"))
3977                .expect_err("engine-internal vault key must be refused");
3978            assert!(
3979                err.to_string().contains("engine-internal"),
3980                "unexpected error for {key}: {err}"
3981            );
3982        }
3983    }
3984}