Skip to main content

reddb_server/runtime/
impl_core.rs

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