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