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