Skip to main content

reddb_server/runtime/
impl_core.rs

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