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::replication::cdc::ChangeRecord;
5use crate::storage::query::ast::TableSource;
6// Authorization surface moved to `super::authz` (issue #1622). Re-export the
7// free IAM/policy-column helpers so this module's remaining dispatch code
8// (and the `impl_core::decision_to_strings` path used by
9// `server::handlers_iam_policy`) keeps calling them unqualified.
10pub(crate) use super::authz::policy_columns::*;
11
12// Graph inline-TVF/analytics moved to `super::graph_tvf` (issue #1625) and the
13// RLS injection family to `super::rls_injection`. Re-export the free fns still
14// referenced from this module (and via `crate::runtime::impl_core::…` paths by
15// sibling files) so those call sites need no edits.
16pub(crate) use super::graph_tvf::{abstract_degree_centrality, is_graph_tvf_name};
17pub(crate) use super::rls_injection::{
18    apply_foreign_table_filters, inject_rls_filters, inject_rls_into_join, rls_is_enabled,
19    rls_policy_filter, rls_policy_filter_for_kind,
20};
21// VCS command parse/execute moved to `super::vcs_command` (issue #1626). The
22// execute methods are inherent `pub(crate)` methods resolved via `self`; the
23// free parse helpers still called from the central dispatch (and by
24// `collections_referenced` here) are re-exported so those call sites need no
25// edits.
26pub(crate) use super::vcs_command::{
27    parse_runtime_vcs_command, strip_explain_prefix, walk_collections,
28};
29
30// Ranking / metrics / SLO / analytics-source DDL moved to
31// `super::impl_ranking` (issue #1627). Execute methods are inherent
32// `pub(crate)` methods resolved via `self`; no call-site edits needed.
33
34// Config / secret / KV resolution moved to `super::impl_config_secret` and the
35// query-audit + control-event families to `super::impl_audit_control`
36// (issue #1628). Methods are inherent `pub(crate)` methods resolved via `self`;
37// the one free fn still called via a `crate::runtime::impl_core::…` path
38// (`control_event_outcome_for_error`, from `impl_backup`) is re-exported so that
39// call site needs no edit. The dispatch here also still calls the three
40// audit-planning free fns, so they are pulled back into scope unqualified.
41pub(crate) use super::impl_audit_control::{
42    control_event_outcome_for_error, query_audit_plan, query_control_event_specs,
43};
44// Config/secret free helpers still called unqualified from the dispatch here.
45pub(crate) use super::impl_config_secret::{
46    insert_config_json_path, secret_sql_value_to_string, seed_storage_deploy_config,
47    show_config_json_result, show_secrets_allows_key,
48};
49pub(super) use super::impl_core_outer_scope::{
50    first_column_values, query_references_outer_scope, relation_scopes_for_query,
51};
52pub(crate) use super::impl_core_result_cache_scopes::collect_table_refs;
53pub(super) use super::impl_core_result_cache_scopes::query_expr_result_cache_scopes;
54
55// Bootstrap / constructor / handle accessors moved to `super::impl_lifecycle`,
56// telemetry / gates / limits / shutdown to `super::impl_telemetry_accessors`,
57// and catalog / stats / maintenance to `super::impl_catalog_accessors`
58// (issue #1629). Methods are inherent `pub(crate)`/`pub` methods resolved via
59// `self`; the two moved free fns still called unqualified from the dispatch
60// here (`view_records_to_entities`) and the runtime-index rehydration paths
61// (`table_row_index_fields`) are re-exported so those call sites need no edit.
62pub(crate) use super::impl_lifecycle::{table_row_index_fields, view_records_to_entities};
63
64pub use super::execution_context::{
65    capture_current_snapshot, clear_current_auth_identity, clear_current_connection_id,
66    clear_current_snapshot, clear_current_tenant, current_auth_identity_for_audit,
67    current_connection_id, current_tenant, entity_visible_under_current_snapshot,
68    entity_visible_with_context, set_current_auth_identity, set_current_connection_id,
69    set_current_snapshot, set_current_tenant, snapshot_bundle, with_snapshot_bundle,
70    SnapshotBundle, SnapshotContext,
71};
72pub(crate) use super::execution_context::{
73    current_auth_identity, current_config_value, current_kv_value, current_role_projected,
74    current_scope_override, current_secret_value, current_snapshot_requires_index_fallback,
75    current_user_projected, has_scope_override_active, parse_set_local_tenant,
76    update_current_config_value, update_current_kv_value, update_current_secret_value,
77    xids_visible_under_current_snapshot, ConfigSnapshotGuard, CurrentSnapshotGuard, KvStoreGuard,
78    ScopeOverrideGuard, SecretStoreGuard, TxLocalTenantGuard,
79};
80
81/// Cheap prefix check for a leading `WITH` keyword. Used to gate the
82/// CTE-aware parse in `execute_query` without paying for a full
83/// lexer pass on every statement. Treats `WITHIN` as not-a-CTE so
84/// `WITHIN TENANT '...' SELECT ...` doesn't mis-route.
85pub(super) fn has_with_prefix(sql: &str) -> bool {
86    let trimmed = sql.trim_start();
87    let head_end = trimmed
88        .find(|c: char| c.is_whitespace() || c == '(')
89        .unwrap_or(trimmed.len());
90    trimmed[..head_end].eq_ignore_ascii_case("WITH")
91}
92
93/// Same as `peek_top_level_as_of` but also returns the table name
94/// targeted by the AS OF clause (when the FROM clause names a
95/// concrete table). `None` for the table slot means scalar SELECT
96/// or a subquery source — callers treat those as "no enforcement".
97pub(super) fn peek_top_level_as_of_with_table(
98    sql: &str,
99) -> Option<(crate::application::vcs::AsOfSpec, Option<String>)> {
100    if !sql
101        .as_bytes()
102        .windows(5)
103        .any(|w| w.eq_ignore_ascii_case(b"as of"))
104    {
105        return None;
106    }
107    let parsed = crate::storage::query::parser::parse(sql).ok()?;
108    let crate::storage::query::ast::QueryExpr::Table(table) = parsed.query else {
109        return None;
110    };
111    let clause = table.as_of?;
112    let table_name = if table.table.is_empty() || table.table == "any" {
113        None
114    } else {
115        Some(table.table.clone())
116    };
117    let spec = match clause {
118        crate::storage::query::ast::AsOfClause::Commit(h) => {
119            crate::application::vcs::AsOfSpec::Commit(h)
120        }
121        crate::storage::query::ast::AsOfClause::Branch(b) => {
122            crate::application::vcs::AsOfSpec::Branch(b)
123        }
124        crate::storage::query::ast::AsOfClause::Tag(t) => crate::application::vcs::AsOfSpec::Tag(t),
125        crate::storage::query::ast::AsOfClause::TimestampMs(ts) => {
126            crate::application::vcs::AsOfSpec::TimestampMs(ts)
127        }
128        crate::storage::query::ast::AsOfClause::Snapshot(x) => {
129            crate::application::vcs::AsOfSpec::Snapshot(x)
130        }
131    };
132    Some((spec, table_name))
133}
134
135pub(super) fn query_has_volatile_builtin(sql: &str) -> bool {
136    // Lowercase the bytes up to the first null/newline into a small
137    // stack buffer for cheap contains() checks. Most SQL fits in the
138    // buffer; longer queries fall back to owned lowercase.
139    const VOLATILE_TOKENS: &[&str] = &[
140        "pg_advisory_lock",
141        "pg_try_advisory_lock",
142        "pg_advisory_unlock",
143        "random()",
144        // `$config.<path>` / `$secret.<path>` resolve mutable runtime config /
145        // vault state at execution time (#1370). A cached result would serve a
146        // stale value after a later `SET CONFIG` / `SET SECRET`, so treat any
147        // query referencing them as volatile (never result-cache it).
148        "$config",
149        "$secret",
150        "$kv",
151        // NOW() / CURRENT_TIMESTAMP / CURRENT_DATE intentionally
152        // omitted for now — they ARE volatile but today's tests rely
153        // on caching them. Revisit once a tighter volatility story
154        // lands.
155    ];
156    let lowered = sql.to_ascii_lowercase();
157    VOLATILE_TOKENS.iter().any(|t| lowered.contains(t))
158}
159
160pub(super) fn query_is_ask_statement(sql: &str) -> bool {
161    let trimmed = sql.trim_start();
162    let head_end = trimmed
163        .find(|c: char| c.is_whitespace() || c == '(' || c == ';')
164        .unwrap_or(trimmed.len());
165    trimmed[..head_end].eq_ignore_ascii_case("ASK")
166}
167
168/// Pick the `(global_mode, collection_mode)` pair for an expression,
169/// or `None` for variants that opt out of intent-locking entirely
170/// (admin statements like `SHOW CONFIG`, transaction control, tenant
171/// toggles).
172///
173/// Phase-1 contract:
174/// - Reads  — `(IX-compatible) (Global, IS) → (Collection, IS)`
175/// - Writes — `(IX-compatible) (Global, IX) → (Collection, IX)`
176/// - DDL    — `(strong)        (Global, IX) → (Collection, X)`
177pub(super) fn intent_lock_modes_for(
178    expr: &QueryExpr,
179) -> Option<(
180    crate::runtime::lock_manager::LockMode,
181    crate::runtime::lock_manager::LockMode,
182)> {
183    use crate::runtime::lock_manager::LockMode::{Exclusive, IntentExclusive, IntentShared};
184
185    match expr {
186        // Reads — IS / IS.
187        QueryExpr::Table(_)
188        | QueryExpr::Join(_)
189        | QueryExpr::Vector(_)
190        | QueryExpr::Hybrid(_)
191        | QueryExpr::Graph(_)
192        | QueryExpr::Path(_)
193        | QueryExpr::Ask(_)
194        | QueryExpr::SearchCommand(_)
195        | QueryExpr::GraphCommand(_)
196        | QueryExpr::RankOf(_)
197        | QueryExpr::ApproxRankOf(_)
198        | QueryExpr::RankRange(_)
199        | QueryExpr::QueueSelect(_) => Some((IntentShared, IntentShared)),
200
201        // Writes — IX / IX. Non-tabular mutations (vector insert,
202        // graph node insert, queue push, timeseries point insert)
203        // don't carry their own dispatch arm here; they ride through
204        // the Insert variant or a command variant covered by the
205        // read-side arm above. P1.T4 expands only the TableQuery-ish
206        // writes; non-tabular kinds inherit when their DML variants
207        // land in later phases.
208        QueryExpr::Insert(_)
209        | QueryExpr::Update(_)
210        | QueryExpr::Delete(_)
211        | QueryExpr::QueueCommand(QueueCommand::Move { .. }) => {
212            Some((IntentExclusive, IntentExclusive))
213        }
214        QueryExpr::QueueCommand(_) => Some((IntentShared, IntentShared)),
215
216        // DDL — IX / X. A DDL against collection `c` blocks all
217        // other writers + readers on `c` but leaves other collections
218        // running (because Global stays IX, not X).
219        QueryExpr::CreateTable(_)
220        | QueryExpr::CreateCollection(_)
221        | QueryExpr::CreateVector(_)
222        | QueryExpr::DropTable(_)
223        | QueryExpr::DropGraph(_)
224        | QueryExpr::DropVector(_)
225        | QueryExpr::DropDocument(_)
226        | QueryExpr::DropKv(_)
227        | QueryExpr::DropCollection(_)
228        | QueryExpr::Truncate(_)
229        | QueryExpr::AlterTable(_)
230        | QueryExpr::CreateIndex(_)
231        | QueryExpr::DropIndex(_)
232        | QueryExpr::CreateTimeSeries(_)
233        | QueryExpr::CreateMetric(_)
234        | QueryExpr::AlterMetric(_)
235        | QueryExpr::CreateSlo(_)
236        | QueryExpr::DropTimeSeries(_)
237        | QueryExpr::CreateQueue(_)
238        | QueryExpr::AlterQueue(_)
239        | QueryExpr::DropQueue(_)
240        | QueryExpr::CreateTree(_)
241        | QueryExpr::DropTree(_)
242        | QueryExpr::CreatePolicy(_)
243        | QueryExpr::DropPolicy(_)
244        | QueryExpr::CreateView(_)
245        | QueryExpr::DropView(_)
246        | QueryExpr::RefreshMaterializedView(_)
247        | QueryExpr::CreateSchema(_)
248        | QueryExpr::DropSchema(_)
249        | QueryExpr::CreateSequence(_)
250        | QueryExpr::DropSequence(_)
251        | QueryExpr::CreateServer(_)
252        | QueryExpr::DropServer(_)
253        | QueryExpr::CreateForeignTable(_)
254        | QueryExpr::DropForeignTable(_) => Some((IntentExclusive, Exclusive)),
255
256        // Admin / control — skip intent locks. `SET TENANT`,
257        // `BEGIN / COMMIT / ROLLBACK`, `SET CONFIG`, `SHOW CONFIG`,
258        // `VACUUM`, etc. don't touch collection data the same way
259        // and the existing transaction layer already serialises the
260        // pieces that matter.
261        _ => None,
262    }
263}
264
265/// Best-effort collection inventory for an expression. Used to pick
266/// `Collection(...)` resources for the intent-lock guard. Overshoots
267/// are fine (take an extra IS, benign); undershoots leak writes past
268/// DDL X locks, so err on the side of listing more names.
269pub(super) fn collections_referenced(expr: &QueryExpr) -> Vec<String> {
270    let mut out = Vec::new();
271    walk_collections(expr, &mut out);
272    out.sort();
273    out.dedup();
274    out
275}
276
277impl RedDBRuntime {
278    /// Execute a query under a typed scope override without embedding
279    /// the tenant / user / role values into the SQL string. Use this
280    /// from transport middleware (HTTP / gRPC / worker loops) where the
281    /// scope is resolved from auth claims and the SQL is a parameterised
282    /// template — avoids the string-concat injection risk of building
283    /// `WITHIN TENANT '<id>' …` manually, and is drop-in compatible with
284    /// prepared statements that didn't know about tenancy.
285    ///
286    /// Precedence matches the `WITHIN` clause: the passed `scope`
287    /// overrides `SET LOCAL TENANT`, which overrides `SET TENANT`.
288    /// The override is pushed on the thread-local scope stack for the
289    /// duration of the call and popped on return — pool-shared
290    /// connections cannot leak it across requests.
291    pub fn execute_query_with_scope(
292        &self,
293        query: &str,
294        scope: crate::runtime::within_clause::ScopeOverride,
295    ) -> RedDBResult<RuntimeQueryResult> {
296        if scope.is_empty() {
297            return self.execute_query(query);
298        }
299        let _scope_guard = ScopeOverrideGuard::install(scope);
300        self.execute_query(query)
301    }
302
303    /// Issue #205 — single lifecycle exit for slow-query logging.
304    ///
305    /// `execute_query_inner` does the real work; this wrapper times it
306    /// and, if elapsed exceeds the configured threshold, hands the
307    /// triple `(QueryKind, elapsed_ms, sql_redacted, scope)` to the
308    /// SlowQueryLogger. The threshold + sample_pct were captured at
309    /// SlowQueryLogger construction (runtime startup), so the per-call
310    /// cost on below-threshold paths is one relaxed atomic load.
311    pub fn execute_query(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
312        let started = std::time::Instant::now();
313        self.inner.node_load_telemetry.query_start();
314        let result = self.execute_query_inner(query);
315        self.finish_query_lifecycle(query, started, result)
316    }
317
318    /// Execute a SQL statement with already-decoded positional bind
319    /// parameters. Transports should call this instead of parsing +
320    /// binding on their side and then reaching for `execute_query_expr`:
321    /// this entry keeps parameterized statements inside the same
322    /// statement lifecycle as textual SQL (snapshot guard, config/secret
323    /// guards, coarse auth, intent locks, slow-query logging, integrity
324    /// tombstone filtering, and causal bookmarks).
325    pub fn execute_query_with_params(
326        &self,
327        query: &str,
328        params: &[Value],
329    ) -> RedDBResult<RuntimeQueryResult> {
330        if params.is_empty() {
331            return self.execute_query(query);
332        }
333        let started = std::time::Instant::now();
334        self.inner.node_load_telemetry.query_start();
335        let result = self.execute_query_with_params_inner(query, params);
336        self.finish_query_lifecycle(query, started, result)
337    }
338
339    fn finish_query_lifecycle(
340        &self,
341        query: &str,
342        started: std::time::Instant,
343        mut result: RedDBResult<RuntimeQueryResult>,
344    ) -> RedDBResult<RuntimeQueryResult> {
345        // Issue #765 / S6 — filter integrity-tombstoned rows out of SELECT
346        // results before they reach any consumer. Fast no-op (one relaxed
347        // atomic load) unless an input-stream digest mismatch has tombstoned
348        // a RID range on this store.
349        if let Ok(ref mut query_result) = result {
350            if query_result.statement_type == "select" {
351                self.filter_integrity_tombstoned(&mut query_result.result);
352            }
353        }
354        let elapsed_ms = started.elapsed().as_millis() as u64;
355
356        // Build EffectiveScope from the same thread-locals frame-build
357        // consults — keeps the slow-log row consistent with the audit /
358        // RLS view of "this statement". `ai_scope()` is the canonical
359        // builder.
360        let scope = self.ai_scope();
361        let kind = match result
362            .as_ref()
363            .map(|r| r.statement_type)
364            .unwrap_or("select")
365        {
366            "select" => crate::telemetry::slow_query_logger::QueryKind::Select,
367            "insert" => crate::telemetry::slow_query_logger::QueryKind::Insert,
368            "update" => crate::telemetry::slow_query_logger::QueryKind::Update,
369            "delete" => crate::telemetry::slow_query_logger::QueryKind::Delete,
370            _ => crate::telemetry::slow_query_logger::QueryKind::Internal,
371        };
372        // SQL redaction: pass the raw query through. The slow-query
373        // logger writes structured JSON so embedded literals stay
374        // escape-safe at the JSON boundary (proven by
375        // `adversarial_sql_is_escape_safe` in slow_query_logger.rs).
376        // PII redaction (e.g. literal masking) is a follow-up.
377        self.inner
378            .slow_query_logger
379            .record(kind, elapsed_ms, query.to_string(), &scope);
380
381        // Issue #1241 — record latency into the bounded per-`kind`
382        // histogram substrate (always, not only above the slow-query
383        // threshold). `started.elapsed()` is re-read here for sub-ms
384        // resolution; the cost is one `Instant::now` plus a handful of
385        // relaxed atomic adds (see `query_latency_telemetry` docs).
386        self.inner
387            .query_latency_telemetry
388            .observe(kind, started.elapsed().as_secs_f64());
389
390        // Issue #1245 — decrement the active-query gauge. One relaxed
391        // atomic sub; the matching increment happened at execute_query /
392        // execute_query_with_params entry.
393        self.inner.node_load_telemetry.query_finish();
394
395        if let Ok(ref mut query_result) = result {
396            if matches!(query_result.statement_type, "insert" | "update" | "delete") {
397                let bookmark = crate::replication::CausalBookmark::new(
398                    self.current_replication_term(),
399                    self.cdc_current_lsn(),
400                );
401                query_result.bookmark = Some(bookmark.encode());
402            }
403        }
404
405        result
406    }
407
408    fn execute_query_with_params_inner(
409        &self,
410        query: &str,
411        params: &[Value],
412    ) -> RedDBResult<RuntimeQueryResult> {
413        let parsed = parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
414        let bound = crate::storage::query::user_params::bind(&parsed, params).map_err(|err| {
415            RedDBError::Validation {
416                message: err.to_string(),
417                validation: crate::json!({
418                    "code": "INVALID_PARAMS",
419                    "surface": "query.params",
420                }),
421            }
422        })?;
423        self.execute_bound_query_expr_in_frame(query, bound)
424    }
425
426    fn execute_bound_query_expr_in_frame(
427        &self,
428        query: &str,
429        expr: QueryExpr,
430    ) -> RedDBResult<RuntimeQueryResult> {
431        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
432        let execution_query = rewritten_query.as_deref().unwrap_or(query);
433        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
434        // Box the guards so the ~640-byte StatementFrameGuards struct lives on
435        // the heap rather than the call stack — important for recursive paths
436        // (view refresh, nested queries) where the stack can be as small as 2 MB.
437        let _frame_guards = Box::new(frame.install(self));
438        let _log_span = crate::telemetry::span::query_span(query).entered();
439
440        let expr = self.rewrite_view_refs(expr);
441        let mode = detect_mode(execution_query);
442        let control_event_specs = query_control_event_specs(&expr);
443        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
444            Ok(guard) => guard,
445            Err(err) => {
446                let outcome = control_event_outcome_for_error(&err);
447                for spec in &control_event_specs {
448                    self.emit_control_event(
449                        spec.kind,
450                        outcome,
451                        spec.action,
452                        spec.resource.clone(),
453                        Some(err.to_string()),
454                        spec.fields.clone(),
455                    )?;
456                }
457                return Err(err);
458            }
459        };
460
461        let mut result = self.dispatch_expr(expr, query, mode)?;
462        if result.statement_type == "select" {
463            self.apply_secret_decryption(&mut result);
464        }
465        Ok(result)
466    }
467
468    pub fn causal_session(&self) -> crate::runtime::CausalSession {
469        crate::runtime::CausalSession {
470            runtime: self.clone(),
471            bookmark: None,
472            wait_timeout: std::time::Duration::from_secs(5),
473        }
474    }
475
476    pub fn wait_for_bookmark(
477        &self,
478        bookmark: &crate::replication::CausalBookmark,
479        timeout: std::time::Duration,
480    ) -> RedDBResult<()> {
481        let deadline = std::time::Instant::now() + timeout;
482        loop {
483            let applied_lsn = self.local_contiguous_applied_lsn();
484            if applied_lsn >= bookmark.commit_lsn() {
485                return Ok(());
486            }
487            let now = std::time::Instant::now();
488            if now >= deadline {
489                return Err(RedDBError::InvalidOperation(format!(
490                    "timed out waiting for causal bookmark lsn {}; applied={}",
491                    bookmark.commit_lsn(),
492                    applied_lsn
493                )));
494            }
495            let remaining = deadline.saturating_duration_since(now);
496            std::thread::sleep(remaining.min(std::time::Duration::from_millis(5)));
497        }
498    }
499
500    fn local_contiguous_applied_lsn(&self) -> u64 {
501        match self.inner.db.options().replication.role {
502            crate::replication::ReplicationRole::Replica { .. } => {
503                self.config_u64("red.replication.last_applied_lsn", 0)
504            }
505            _ => self.cdc_current_lsn(),
506        }
507    }
508
509    #[inline(never)]
510    pub(super) fn execute_query_inner(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
511        // ── ULTRA-TURBO: autocommit `SELECT * FROM t WHERE _entity_id = N` ──
512        //
513        // Moved above every boot-cost the normal path pays (WITHIN
514        // strip, SET LOCAL parse, tx_local_tenants read, snapshot
515        // guard, tracing span, tx_contexts read) because the bench's
516        // `select_point` scenario was observed at 28× vs PostgreSQL —
517        // the dominant cost wasn't the entity fetch but the ceremony
518        // before it. Only fires when there's no ambient transaction
519        // context or WITHIN override, so the snapshot install we skip
520        // truly is a no-op for this query.
521        if !has_scope_override_active()
522            && !query.trim_start().starts_with("WITHIN")
523            && !query.trim_start().starts_with("within")
524            && !self.inner.query_audit.has_rules()
525            && !self
526                .inner
527                .tx_contexts
528                .read()
529                .contains_key(&current_connection_id())
530        {
531            if let Some(result) = self.try_fast_entity_lookup(query) {
532                return result;
533            }
534        }
535
536        // `WITHIN TENANT '<id>' [USER '<u>'] [AS ROLE '<r>'] <stmt>` —
537        // strip the prefix, push a stack-scoped override, recurse on
538        // the inner statement, pop on return. Stack lives in a
539        // thread-local but is balanced by the RAII guard, so a
540        // pool-shared connection cannot leak the override across
541        // requests and an early `?` return still pops cleanly.
542        match crate::runtime::within_clause::try_strip_within_prefix(query) {
543            Ok(Some((scope, inner))) => {
544                let _scope_guard = ScopeOverrideGuard::install(scope);
545                // Re-enter the inner path, NOT `execute_query`, so the
546                // slow-query lifecycle hook records exactly one row per
547                // top-level statement (the WITHIN-stripped form would
548                // double-record).
549                return self.execute_query_inner(inner);
550            }
551            Ok(None) => {}
552            Err(msg) => return Err(RedDBError::Query(msg)),
553        }
554
555        // `EXPLAIN <stmt>` — introspection. Runs the planner on the
556        // inner statement (WITHOUT executing it) and returns the
557        // CanonicalLogicalNode tree as rows so the caller can see the
558        // operator shape and estimated cost. `EXPLAIN ALTER FOR ...`
559        // is a distinct schema-diff command and continues down the
560        // regular SQL path.
561        if let Some(inner) = strip_explain_prefix(query) {
562            return self.explain_as_rows(query, inner);
563        }
564
565        // `SET LOCAL TENANT '<id>'` — write the per-transaction tenant
566        // override and return. Outside a transaction the statement is
567        // an error (matches PG semantics: SET LOCAL only takes effect
568        // within an active transaction).
569        if let Some(value) = parse_set_local_tenant(query)? {
570            let conn_id = current_connection_id();
571            if !self.inner.tx_contexts.read().contains_key(&conn_id) {
572                return Err(RedDBError::Query(
573                    "SET LOCAL TENANT requires an active transaction".to_string(),
574                ));
575            }
576            self.inner
577                .tx_local_tenants
578                .write()
579                .insert(conn_id, value.clone());
580            return Ok(RuntimeQueryResult::ok_message(
581                query.to_string(),
582                &match &value {
583                    Some(id) => format!("local tenant set: {id}"),
584                    None => "local tenant cleared".to_string(),
585                },
586                "set_local_tenant",
587            ));
588        }
589
590        if super::red_schema::is_system_schema_write(query) {
591            return Err(RedDBError::Query(
592                super::red_schema::READ_ONLY_ERROR.to_string(),
593            ));
594        }
595
596        if let Some(command) = parse_runtime_vcs_command(query) {
597            return self.execute_vcs_command(query, detect_mode(query), command?);
598        }
599
600        if let Some(create_source) = super::analytics_source_catalog::parse_create_statement(query)?
601        {
602            return self.execute_create_analytics_source(query, create_source);
603        }
604
605        // Issue #790 — `READ METRIC <path>` is intentionally rejected at
606        // v0. The descriptor itself is readable through
607        // `red.analytics.metrics`; the *output* read returns a
608        // structured error so callers can tell "execution engine not yet
609        // built" apart from "metric does not exist".
610        if let Some(path) = super::metric_descriptor_catalog::parse_read_metric_statement(query) {
611            return Err(super::metric_descriptor_catalog::read_output_unsupported(
612                &path,
613            ));
614        }
615
616        // Issue #918 / ADR 0035 — leaderboard rank capability catalog
617        // declarations are still recognised before the general parser.
618        // Rank reads themselves are parser AST nodes, including Redis-flavor
619        // Z* sugar that desugars to the same canonical rank shapes.
620        if let Some(parsed) = super::ranking_descriptor_catalog::parse_create_ranking(query) {
621            return self.execute_create_ranking(query, parsed?);
622        }
623        if super::ranking_descriptor_catalog::parse_show_rankings(query) {
624            return self.execute_show_rankings(query);
625        }
626
627        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
628        let execution_query = rewritten_query.as_deref().unwrap_or(query);
629
630        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
631        let _frame_guards = Box::new(frame.install(self));
632
633        // Phase 6 logging: enter a span stamped with conn_id / tenant
634        // / query_len. Every downstream tracing::info!/warn!/error!
635        // inherits these fields — no need to thread them manually
636        // through storage/scan layers. Entered AFTER the WITHIN /
637        // SET LOCAL TENANT resolution above so the span reflects the
638        // effective scope for this statement.
639        let _log_span = crate::telemetry::span::query_span(query).entered();
640
641        // ── CTE prelude (#41) — `WITH x AS (...) SELECT ... FROM x` ──
642        if let Some(rewritten) = frame.prepare_cte(execution_query)? {
643            return self.execute_query_expr(rewritten);
644        }
645
646        // ── TURBO: bypass SQL parse for SELECT * FROM x WHERE _entity_id = N ──
647        if !self.inner.query_audit.has_rules() {
648            if let Some(result) = self.try_fast_entity_lookup(execution_query) {
649                return result;
650            }
651        }
652
653        // ── Result cache: return cached result if still fresh (30s TTL) ──
654        if !self.inner.query_audit.has_rules() {
655            if let Some(result) = frame.read_result_cache(self) {
656                return Ok(result);
657            }
658        }
659
660        let prepared = frame.prepare_statement(self, execution_query)?;
661        let mode = prepared.mode;
662        let expr = prepared.expr;
663
664        let statement = query_expr_name(&expr);
665        let result_cache_scopes = query_expr_result_cache_scopes(&expr);
666        let control_event_specs = query_control_event_specs(&expr);
667        let query_audit_plan = query_audit_plan(&expr);
668
669        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
670            Ok(guard) => guard,
671            Err(err) => {
672                let outcome = control_event_outcome_for_error(&err);
673                for spec in &control_event_specs {
674                    self.emit_control_event(
675                        spec.kind,
676                        outcome,
677                        spec.action,
678                        spec.resource.clone(),
679                        Some(err.to_string()),
680                        spec.fields.clone(),
681                    )?;
682                }
683                return Err(err);
684            }
685        };
686        let frame_iface: &dyn super::statement_frame::ReadFrame = &frame;
687        let query_audit_started = std::time::Instant::now();
688
689        let query_result = match expr {
690            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
691                // Apply MVCC visibility + RLS gate while materialising the
692                // graph: every node entity is screened against the source
693                // collection's policy chain (basic and `Nodes`-targeted)
694                // and dropped when the caller's tenant / role doesn't
695                // admit it. Edges are pruned automatically because the
696                // graph builder skips edges whose endpoints aren't in
697                // `allowed_nodes`.
698                let (graph, node_properties, edge_properties) =
699                    self.materialize_graph_with_rls()?;
700                let result =
701                    crate::storage::query::unified::UnifiedExecutor::execute_on_with_graph_properties(
702                        &graph,
703                        &expr,
704                        node_properties,
705                        edge_properties,
706                    )
707                        .map_err(|err| RedDBError::Query(err.to_string()))?;
708
709                Ok(RuntimeQueryResult {
710                    query: query.to_string(),
711                    mode,
712                    statement,
713                    engine: "materialized-graph",
714                    result,
715                    affected_rows: 0,
716                    statement_type: "select",
717                    bookmark: None,
718                })
719            }
720            QueryExpr::Table(table) => {
721                let table = self.resolve_table_expr_subqueries(
722                    table,
723                    &frame as &dyn super::statement_frame::ReadFrame,
724                )?;
725                // Table-valued functions (e.g. components(g)) dispatch to a
726                // read-only executor before any catalog/virtual-table routing
727                // (issue #795).
728                if let Some(TableSource::Function {
729                    name,
730                    args,
731                    named_args,
732                }) = table.source.clone()
733                {
734                    // The graph-collection form is cacheable (issue #802): the
735                    // result-cache read at the top of this function keys on the
736                    // query string, and `result_cache_scopes` carries the graph
737                    // collection (see `collect_table_source_scopes`) so a write
738                    // to it invalidates the entry. Deterministic algorithm
739                    // output is worth caching at any row count, so the write
740                    // bypasses the generic ≤5-row payload heuristic.
741                    let tvf_result = RuntimeQueryResult {
742                        query: query.to_string(),
743                        mode,
744                        statement,
745                        engine: "runtime-graph-tvf",
746                        result: self.execute_table_function(&name, &args, &named_args)?,
747                        affected_rows: 0,
748                        statement_type: "select",
749                        bookmark: None,
750                    };
751                    frame.write_result_cache(self, &tvf_result, result_cache_scopes.clone());
752                    return Ok(tvf_result);
753                }
754                // Inline-graph TVF (issue #799): the graph is supplied by two
755                // subqueries instead of a collection reference. Unlike the
756                // graph-collection form, the result IS cacheable — its cache
757                // key is the query string (the result-cache read at the top of
758                // `execute_query_inner` keys on it) and `result_cache_scopes`
759                // already carries the `nodes`/`edges` source collections, so a
760                // write to any of them invalidates the entry.
761                if let Some(TableSource::InlineGraphFunction {
762                    name,
763                    nodes,
764                    edges,
765                    named_args,
766                }) = table.source.clone()
767                {
768                    let inline_result = RuntimeQueryResult {
769                        query: query.to_string(),
770                        mode,
771                        statement,
772                        engine: "runtime-graph-tvf-inline",
773                        result: self.execute_inline_graph_function(
774                            &name,
775                            &nodes,
776                            &edges,
777                            &named_args,
778                        )?,
779                        affected_rows: 0,
780                        statement_type: "select",
781                        bookmark: None,
782                    };
783                    frame.write_result_cache(self, &inline_result, result_cache_scopes);
784                    return Ok(inline_result);
785                }
786                if super::red_schema::is_virtual_table(&table.table) {
787                    return Ok(RuntimeQueryResult {
788                        query: query.to_string(),
789                        mode,
790                        statement,
791                        engine: "runtime-red-schema",
792                        result: super::red_schema::red_query(
793                            self,
794                            &table.table,
795                            &table,
796                            &frame as &dyn super::statement_frame::ReadFrame,
797                        )?,
798                        affected_rows: 0,
799                        statement_type: "select",
800                        bookmark: None,
801                    });
802                }
803
804                // `<graph>.<output>` analytics virtual view (issue #800).
805                // Recomputed on demand — intentionally not result-cached, so it
806                // always reflects the current graph data.
807                if let Some(view_result) = self.try_resolve_analytics_view(
808                    &table,
809                    &frame as &dyn super::statement_frame::ReadFrame,
810                )? {
811                    return Ok(RuntimeQueryResult {
812                        query: query.to_string(),
813                        mode,
814                        statement,
815                        engine: "runtime-graph-analytics-view",
816                        result: view_result,
817                        affected_rows: 0,
818                        statement_type: "select",
819                        bookmark: None,
820                    });
821                }
822
823                if let Some(result) = self.execute_probabilistic_select(&table)? {
824                    return Ok(RuntimeQueryResult {
825                        query: query.to_string(),
826                        mode,
827                        statement,
828                        engine: "runtime-probabilistic",
829                        result,
830                        affected_rows: 0,
831                        statement_type: "select",
832                        bookmark: None,
833                    });
834                }
835
836                // Foreign-table intercept (Phase 3.2.2 PG parity).
837                //
838                // When the referenced table matches a `CREATE FOREIGN TABLE`
839                // registration, short-circuit into the FDW scan. Phase 3.2
840                // wrappers don't yet support pushdown, so filters/projections
841                // apply post-scan via `apply_foreign_table_filters` — good
842                // enough for correctness; perf work lands in 3.2.3.
843                if self.inner.foreign_tables.is_foreign_table(&table.table) {
844                    let records = self
845                        .inner
846                        .foreign_tables
847                        .scan(&table.table)
848                        .map_err(|e| RedDBError::Internal(e.to_string()))?;
849                    let result = apply_foreign_table_filters(records, &table);
850                    return Ok(RuntimeQueryResult {
851                        query: query.to_string(),
852                        mode,
853                        statement,
854                        engine: "runtime-fdw",
855                        result,
856                        affected_rows: 0,
857                        statement_type: "select",
858                        bookmark: None,
859                    });
860                }
861
862                // Row-Level Security enforcement (Phase 2.5.2 PG parity).
863                //
864                // When RLS is enabled on this table, fetch every policy
865                // that applies to the current (role, SELECT) pair and
866                // fold them into the query's WHERE clause: policies
867                // OR-combine (any of them admitting the row is enough),
868                // then AND into the caller's existing filter.
869                //
870                // Anonymous callers (no thread-local identity) pass
871                // `role = None`; policies with a specific `TO role`
872                // clause skip, but `TO PUBLIC` policies still apply.
873                //
874                // When `inject_rls_filters` returns `None` the table has
875                // RLS enabled but no policy admits the caller's role —
876                // short-circuit with an empty result set instead of
877                // synthesising a contradiction filter.
878                let Some(table_with_rls) = self.authorize_relational_table_select(
879                    table,
880                    &frame as &dyn super::statement_frame::ReadFrame,
881                )?
882                else {
883                    let empty = crate::storage::query::unified::UnifiedResult::empty();
884                    return Ok(RuntimeQueryResult {
885                        query: query.to_string(),
886                        mode,
887                        statement,
888                        engine: "runtime-table-rls",
889                        result: empty,
890                        affected_rows: 0,
891                        statement_type: "select",
892                        bookmark: None,
893                    });
894                };
895                Ok(RuntimeQueryResult {
896                    query: query.to_string(),
897                    mode,
898                    statement,
899                    engine: "runtime-table",
900                    // #885: lend the frame-owned row-buffer arena to the
901                    // streaming path so chunk buffers are reused across
902                    // this statement's chunk-fetches instead of allocated
903                    // fresh per chunk. This is the table-query dispatch
904                    // that runs under a `StatementExecutionFrame`; the
905                    // frameless prepared/subquery paths keep `None`.
906                    result: execute_runtime_table_query_in(
907                        &self.inner.db,
908                        &table_with_rls,
909                        Some(&self.inner.index_store),
910                        Some(frame.row_arena()),
911                    )?,
912                    affected_rows: 0,
913                    statement_type: "select",
914                    bookmark: None,
915                })
916            }
917            QueryExpr::Join(join) => {
918                // Fold per-table RLS filters into each `QueryExpr::Table`
919                // leaf of the join tree before executing. Without this
920                // the join executor scans both tables raw and ignores
921                // policies — a `WITHIN TENANT 'x'` against a join of
922                // two tenant-scoped tables would leak cross-tenant rows.
923                // When any leaf has RLS enabled and zero matching policy,
924                // short-circuit to an empty join result instead of
925                // emitting a contradiction filter.
926                let join_with_rls = match self.authorize_relational_join_select(
927                    join,
928                    &frame as &dyn super::statement_frame::ReadFrame,
929                )? {
930                    Some(j) => j,
931                    None => {
932                        return Ok(RuntimeQueryResult {
933                            query: query.to_string(),
934                            mode,
935                            statement,
936                            engine: "runtime-join-rls",
937                            result: crate::storage::query::unified::UnifiedResult::empty(),
938                            affected_rows: 0,
939                            statement_type: "select",
940                            bookmark: None,
941                        });
942                    }
943                };
944                Ok(RuntimeQueryResult {
945                    query: query.to_string(),
946                    mode,
947                    statement,
948                    engine: "runtime-join",
949                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
950                    affected_rows: 0,
951                    statement_type: "select",
952                    bookmark: None,
953                })
954            }
955            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
956                query: query.to_string(),
957                mode,
958                statement,
959                engine: "runtime-vector",
960                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
961                affected_rows: 0,
962                statement_type: "select",
963                bookmark: None,
964            }),
965            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
966                query: query.to_string(),
967                mode,
968                statement,
969                engine: "runtime-hybrid",
970                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
971                affected_rows: 0,
972                statement_type: "select",
973                bookmark: None,
974            }),
975            QueryExpr::RankOf(ref rank) => self.execute_rank_of(query, rank),
976            QueryExpr::ApproxRankOf(ref rank) => self.execute_approx_rank_of(query, rank),
977            QueryExpr::RankRange(ref range) => self.execute_rank_range(query, range),
978            // DML execution
979            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
980                Err(RedDBError::Query(
981                    super::red_schema::READ_ONLY_ERROR.to_string(),
982                ))
983            }
984            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
985                Err(RedDBError::Query(
986                    super::red_schema::READ_ONLY_ERROR.to_string(),
987                ))
988            }
989            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
990                Err(RedDBError::Query(
991                    super::red_schema::READ_ONLY_ERROR.to_string(),
992                ))
993            }
994            QueryExpr::Insert(ref insert) => self
995                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
996                    self.execute_insert(query, insert)
997                }),
998            QueryExpr::Update(ref update) => self
999                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
1000                    self.execute_update(query, update)
1001                }),
1002            QueryExpr::Delete(ref delete) => self
1003                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
1004                    self.execute_delete(query, delete)
1005                }),
1006            // DDL execution
1007            QueryExpr::CreateTable(ref create) => self.execute_create_table(query, create),
1008            QueryExpr::CreateCollection(ref create) => {
1009                self.execute_create_collection(query, create)
1010            }
1011            QueryExpr::CreateVector(ref create) => self.execute_create_vector(query, create),
1012            QueryExpr::DropTable(ref drop_tbl) => self.execute_drop_table(query, drop_tbl),
1013            QueryExpr::DropGraph(ref drop_graph) => self.execute_drop_graph(query, drop_graph),
1014            QueryExpr::DropVector(ref drop_vector) => self.execute_drop_vector(query, drop_vector),
1015            QueryExpr::DropDocument(ref drop_document) => {
1016                self.execute_drop_document(query, drop_document)
1017            }
1018            QueryExpr::DropKv(ref drop_kv) => self.execute_drop_kv(query, drop_kv),
1019            QueryExpr::DropCollection(ref drop_collection) => {
1020                self.execute_drop_collection(query, drop_collection)
1021            }
1022            QueryExpr::Truncate(ref truncate) => self.execute_truncate(query, truncate),
1023            QueryExpr::AlterTable(ref alter) => self.execute_alter_table(query, alter),
1024            QueryExpr::CreateVcsRef(ref create) => self.execute_create_vcs_ref(query, create),
1025            QueryExpr::DropVcsRef(ref drop_ref) => self.execute_drop_vcs_ref(query, drop_ref),
1026            QueryExpr::ExplainAlter(ref explain) => self.execute_explain_alter(query, explain),
1027            // Graph analytics commands
1028            QueryExpr::GraphCommand(ref cmd) => self.execute_graph_command(query, cmd),
1029            // Search commands
1030            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query, cmd),
1031            // ASK: RAG query with LLM synthesis
1032            QueryExpr::Ask(ref ask) => self.execute_ask(query, ask),
1033            QueryExpr::CreateIndex(ref create_idx) => self.execute_create_index(query, create_idx),
1034            QueryExpr::DropIndex(ref drop_idx) => self.execute_drop_index(query, drop_idx),
1035            QueryExpr::ProbabilisticCommand(ref cmd) => {
1036                self.execute_probabilistic_command(query, cmd)
1037            }
1038            // Time-series DDL
1039            QueryExpr::CreateTimeSeries(ref ts) => self.execute_create_timeseries(query, ts),
1040            QueryExpr::CreateMetric(ref metric) => self.execute_create_metric(query, metric),
1041            QueryExpr::AlterMetric(ref alter) => self.execute_alter_metric(query, alter),
1042            QueryExpr::CreateSlo(ref slo) => self.execute_create_slo(query, slo),
1043            QueryExpr::DropTimeSeries(ref ts) => self.execute_drop_timeseries(query, ts),
1044            // Queue DDL and commands
1045            QueryExpr::CreateQueue(ref q) => self.execute_create_queue(query, q),
1046            QueryExpr::AlterQueue(ref q) => self.execute_alter_queue(query, q),
1047            QueryExpr::DropQueue(ref q) => self.execute_drop_queue(query, q),
1048            QueryExpr::QueueSelect(ref q) => self.execute_queue_select(query, q),
1049            QueryExpr::QueueCommand(ref cmd) => self
1050                .with_deferred_store_wal_if_transaction(|| self.execute_queue_command(query, cmd)),
1051            QueryExpr::EventsBackfill(ref backfill) => {
1052                self.execute_events_backfill(query, backfill)
1053            }
1054            QueryExpr::EventsBackfillStatus { ref collection } => Err(RedDBError::Query(format!(
1055                "EVENTS BACKFILL STATUS for '{collection}' is not implemented in this slice"
1056            ))),
1057            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query, cmd),
1058            QueryExpr::ConfigCommand(ref cmd) => self.execute_config_command(query, cmd),
1059            QueryExpr::CreateTree(ref tree) => self.execute_create_tree(query, tree),
1060            QueryExpr::DropTree(ref tree) => self.execute_drop_tree(query, tree),
1061            QueryExpr::TreeCommand(ref cmd) => self.execute_tree_command(query, cmd),
1062            // SET CONFIG key = value
1063            QueryExpr::SetConfig { ref key, ref value } => {
1064                if key.starts_with("red.secret.") {
1065                    return Err(RedDBError::Query(
1066                        "red.secret.* is reserved for vault secrets; use SET SECRET".to_string(),
1067                    ));
1068                }
1069                if key.starts_with("red.secrets.") {
1070                    return Err(RedDBError::Query(
1071                        "red.secrets.* is reserved for vault secrets; use SET SECRET".to_string(),
1072                    ));
1073                }
1074                match self.check_managed_config_write_for_set_config(key) {
1075                    Err(err) => Err(err),
1076                    Ok(()) => {
1077                        let store = self.inner.db.store();
1078                        let json_val = match value {
1079                            Value::Text(s) => crate::serde_json::Value::String(s.to_string()),
1080                            Value::Integer(n) => crate::serde_json::Value::Number(*n as f64),
1081                            Value::Float(n) => crate::serde_json::Value::Number(*n),
1082                            Value::Boolean(b) => crate::serde_json::Value::Bool(*b),
1083                            _ => crate::serde_json::Value::String(value.to_string()),
1084                        };
1085                        store.set_config_tree(key, &json_val);
1086                        update_current_config_value(key, value.clone());
1087                        // Config changes can flip runtime behavior mid-session
1088                        // (auto_decrypt, auto_encrypt, etc.) — invalidate the
1089                        // result cache so subsequent reads re-execute against
1090                        // the new config.
1091                        self.invalidate_result_cache();
1092                        Ok(RuntimeQueryResult::ok_message(
1093                            query.to_string(),
1094                            &format!("config set: {key}"),
1095                            "set",
1096                        ))
1097                    }
1098                }
1099            }
1100            // SET SECRET key = value
1101            QueryExpr::SetSecret { ref key, ref value } => {
1102                if key.starts_with("red.config.") {
1103                    return Err(RedDBError::Query(
1104                        "red.config.* is reserved for config; use SET CONFIG".to_string(),
1105                    ));
1106                }
1107                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1108                    RedDBError::Query("SET SECRET requires an enabled, unsealed vault".to_string())
1109                })?;
1110                self.check_secret_write_privilege(&auth_store, key)?;
1111                if matches!(value, Value::Null) {
1112                    auth_store
1113                        .vault_kv_try_delete(key)
1114                        .map_err(|err| RedDBError::Query(err.to_string()))?;
1115                    update_current_secret_value(key, None);
1116                    self.invalidate_result_cache();
1117                    return Ok(RuntimeQueryResult::ok_message(
1118                        query.to_string(),
1119                        &format!("secret deleted: {key}"),
1120                        "delete_secret",
1121                    ));
1122                }
1123                let value = secret_sql_value_to_string(value)?;
1124                auth_store
1125                    .vault_kv_try_set(key.clone(), value.clone())
1126                    .map_err(|err| RedDBError::Query(err.to_string()))?;
1127                update_current_secret_value(key, Some(value));
1128                self.invalidate_result_cache();
1129                Ok(RuntimeQueryResult::ok_message(
1130                    query.to_string(),
1131                    &format!("secret set: {key}"),
1132                    "set_secret",
1133                ))
1134            }
1135            // DELETE SECRET key
1136            QueryExpr::DeleteSecret { ref key } => {
1137                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1138                    RedDBError::Query(
1139                        "DELETE SECRET requires an enabled, unsealed vault".to_string(),
1140                    )
1141                })?;
1142                self.check_secret_write_privilege(&auth_store, key)?;
1143                let deleted = auth_store
1144                    .vault_kv_try_delete(key)
1145                    .map_err(|err| RedDBError::Query(err.to_string()))?;
1146                if deleted {
1147                    update_current_secret_value(key, None);
1148                }
1149                self.invalidate_result_cache();
1150                Ok(RuntimeQueryResult::ok_message(
1151                    query.to_string(),
1152                    &format!("secret deleted: {key}"),
1153                    if deleted {
1154                        "delete_secret"
1155                    } else {
1156                        "delete_secret_not_found"
1157                    },
1158                ))
1159            }
1160            // SET KV key = value — plain (non-encrypted) user KV entry (#1602)
1161            QueryExpr::SetKv { ref key, ref value } => {
1162                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1163                    RedDBError::Query("SET KV requires an auth store".to_string())
1164                })?;
1165                self.check_kv_write_privilege(&auth_store, key)?;
1166                // `SET KV k = NULL` deletes, mirroring `SET SECRET`.
1167                if matches!(value, Value::Null) {
1168                    auth_store.plain_kv_delete(key);
1169                    update_current_kv_value(key, None);
1170                    self.invalidate_result_cache();
1171                    return Ok(RuntimeQueryResult::ok_message(
1172                        query.to_string(),
1173                        &format!("kv deleted: {key}"),
1174                        "delete_kv",
1175                    ));
1176                }
1177                let value = secret_sql_value_to_string(value)?;
1178                auth_store.plain_kv_set(key.clone(), value.clone());
1179                update_current_kv_value(key, Some(value));
1180                self.invalidate_result_cache();
1181                Ok(RuntimeQueryResult::ok_message(
1182                    query.to_string(),
1183                    &format!("kv set: {key}"),
1184                    "set_kv",
1185                ))
1186            }
1187            // DELETE KV key
1188            QueryExpr::DeleteKv { ref key } => {
1189                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1190                    RedDBError::Query("DELETE KV requires an auth store".to_string())
1191                })?;
1192                self.check_kv_write_privilege(&auth_store, key)?;
1193                let deleted = auth_store.plain_kv_delete(key);
1194                if deleted {
1195                    update_current_kv_value(key, None);
1196                }
1197                self.invalidate_result_cache();
1198                Ok(RuntimeQueryResult::ok_message(
1199                    query.to_string(),
1200                    &format!("kv deleted: {key}"),
1201                    if deleted {
1202                        "delete_kv"
1203                    } else {
1204                        "delete_kv_not_found"
1205                    },
1206                ))
1207            }
1208            // SHOW SECRET[S] [prefix]
1209            QueryExpr::ShowSecrets { ref prefix } => {
1210                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1211                    RedDBError::Query("SHOW SECRET requires an enabled, unsealed vault".to_string())
1212                })?;
1213                if !auth_store.is_vault_backed() {
1214                    return Err(RedDBError::Query(
1215                        "SHOW SECRET requires an enabled, unsealed vault".to_string(),
1216                    ));
1217                }
1218                let mut keys = auth_store.vault_kv_keys();
1219                keys.sort();
1220                let mut result = UnifiedResult::with_columns(vec![
1221                    "key".into(),
1222                    "value".into(),
1223                    "status".into(),
1224                ]);
1225                for key in keys {
1226                    if !show_secrets_allows_key(&key) {
1227                        continue;
1228                    }
1229                    if let Some(ref pfx) = prefix {
1230                        if !key.starts_with(pfx) {
1231                            continue;
1232                        }
1233                    }
1234                    let mut record = UnifiedRecord::new();
1235                    record.set("key", Value::text(key));
1236                    record.set("value", Value::text("***"));
1237                    record.set("status", Value::text("active"));
1238                    result.push(record);
1239                }
1240                Ok(RuntimeQueryResult {
1241                    query: query.to_string(),
1242                    mode,
1243                    statement: "show_secrets",
1244                    engine: "runtime-secret",
1245                    result,
1246                    affected_rows: 0,
1247                    statement_type: "select",
1248                    bookmark: None,
1249                })
1250            }
1251            // SHOW CONFIG [prefix] [AS JSON|FORMAT JSON]
1252            QueryExpr::ShowConfig {
1253                ref prefix,
1254                as_json,
1255            } => {
1256                let store = self.inner.db.store();
1257                let all_collections = store.list_collections();
1258                if !all_collections.contains(&"red_config".to_string()) {
1259                    if as_json {
1260                        return Ok(show_config_json_result(
1261                            query,
1262                            mode,
1263                            prefix,
1264                            crate::serde_json::Value::Object(crate::serde_json::Map::new()),
1265                        ));
1266                    }
1267                    let result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1268                    return Ok(RuntimeQueryResult {
1269                        query: query.to_string(),
1270                        mode,
1271                        statement: "show_config",
1272                        engine: "runtime-config",
1273                        result,
1274                        affected_rows: 0,
1275                        statement_type: "select",
1276                        bookmark: None,
1277                    });
1278                }
1279                let manager = store
1280                    .get_collection("red_config")
1281                    .ok_or_else(|| RedDBError::NotFound("red_config".to_string()))?;
1282                let entities = manager.query_all(|_| true);
1283                let mut latest = std::collections::BTreeMap::<String, (u64, Value, Value)>::new();
1284                for entity in entities {
1285                    if let EntityData::Row(ref row) = entity.data {
1286                        if let Some(ref named) = row.named {
1287                            let key_val = named.get("key").cloned().unwrap_or(Value::Null);
1288                            let val = named.get("value").cloned().unwrap_or(Value::Null);
1289                            let key_str = match &key_val {
1290                                Value::Text(s) => s.as_ref(),
1291                                _ => continue,
1292                            };
1293                            if let Some(ref pfx) = prefix {
1294                                if !key_str.starts_with(pfx.as_str()) {
1295                                    continue;
1296                                }
1297                            }
1298                            let entity_id = entity.id.raw();
1299                            match latest.get(key_str) {
1300                                Some((prev_id, _, _)) if *prev_id > entity_id => {}
1301                                _ => {
1302                                    latest.insert(key_str.to_string(), (entity_id, key_val, val));
1303                                }
1304                            }
1305                        }
1306                    }
1307                }
1308                if as_json {
1309                    let mut tree = crate::serde_json::Value::Object(crate::serde_json::Map::new());
1310                    for (key, (_, _, val)) in latest {
1311                        let relative = match prefix {
1312                            Some(pfx) if key == *pfx => "",
1313                            Some(pfx) => key
1314                                .strip_prefix(pfx.as_str())
1315                                .and_then(|tail| tail.strip_prefix('.'))
1316                                .unwrap_or(key.as_str()),
1317                            None => key.as_str(),
1318                        };
1319                        insert_config_json_path(
1320                            &mut tree,
1321                            relative,
1322                            crate::presentation::entity_json::storage_value_to_json(&val),
1323                        );
1324                    }
1325                    return Ok(show_config_json_result(query, mode, prefix, tree));
1326                }
1327                let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1328                for (_, key_val, val) in latest.into_values() {
1329                    let mut record = UnifiedRecord::new();
1330                    record.set("key", key_val);
1331                    record.set("value", val);
1332                    result.push(record);
1333                }
1334                Ok(RuntimeQueryResult {
1335                    query: query.to_string(),
1336                    mode,
1337                    statement: "show_config",
1338                    engine: "runtime-config",
1339                    result,
1340                    affected_rows: 0,
1341                    statement_type: "select",
1342                    bookmark: None,
1343                })
1344            }
1345            // Session-local multi-tenancy handle (Phase 2.5.3).
1346            //
1347            // SET TENANT 'id' / SET TENANT NULL / RESET TENANT — writes
1348            // the thread-local; SHOW TENANT returns it. Paired with the
1349            // CURRENT_TENANT() scalar for use in RLS policies.
1350            QueryExpr::SetTenant(ref value) => {
1351                match value {
1352                    Some(id) => set_current_tenant(id.clone()),
1353                    None => clear_current_tenant(),
1354                }
1355                Ok(RuntimeQueryResult::ok_message(
1356                    query.to_string(),
1357                    &match value {
1358                        Some(id) => format!("tenant set: {id}"),
1359                        None => "tenant cleared".to_string(),
1360                    },
1361                    "set_tenant",
1362                ))
1363            }
1364            QueryExpr::ShowTenant => {
1365                let mut result = UnifiedResult::with_columns(vec!["tenant".into()]);
1366                let mut record = UnifiedRecord::new();
1367                record.set(
1368                    "tenant",
1369                    current_tenant().map(Value::text).unwrap_or(Value::Null),
1370                );
1371                result.push(record);
1372                Ok(RuntimeQueryResult {
1373                    query: query.to_string(),
1374                    mode,
1375                    statement: "show_tenant",
1376                    engine: "runtime-tenant",
1377                    result,
1378                    affected_rows: 0,
1379                    statement_type: "select",
1380                    bookmark: None,
1381                })
1382            }
1383            // Transaction control (Phase 2.3 PG parity).
1384            //
1385            // BEGIN allocates a real `Xid` and stores a `TxnContext` keyed by
1386            // the current connection's id. COMMIT/ROLLBACK release it through
1387            // the `SnapshotManager` so future snapshots see the correct set of
1388            // active/aborted transactions.
1389            //
1390            // Tuple stamping (xmin/xmax) and read-path visibility filtering
1391            // land in Phase 2.3.2 — this dispatch only manages the snapshot
1392            // registry. Statements running outside a TxnContext still behave
1393            // as autocommit (xid=0 → visible to every snapshot).
1394            QueryExpr::TransactionControl(ref ctl) => {
1395                use crate::storage::query::ast::TxnControl;
1396                use crate::storage::transaction::snapshot::{TxnContext, Xid};
1397                use crate::storage::transaction::IsolationLevel;
1398
1399                // Phase 2.3 keys transactions by a thread-local connection id.
1400                // The stdio/gRPC paths wire a real per-connection id later;
1401                // for embedded use (one RedDBRuntime per process-ish caller)
1402                // we fall back to a deterministic placeholder.
1403                let conn_id = current_connection_id();
1404
1405                let (kind, msg) = match ctl {
1406                    TxnControl::Begin(requested_isolation) => {
1407                        let isolation = requested_isolation
1408                            .map(IsolationLevel::from)
1409                            .unwrap_or(IsolationLevel::SnapshotIsolation);
1410                        let mgr = Arc::clone(&self.inner.snapshot_manager);
1411                        let xid = mgr.begin();
1412                        if isolation == IsolationLevel::Serializable {
1413                            mgr.begin_serializable(xid);
1414                        }
1415                        let snapshot = mgr.snapshot(xid);
1416                        let ctx = TxnContext {
1417                            xid,
1418                            isolation,
1419                            snapshot,
1420                            savepoints: Vec::new(),
1421                            released_sub_xids: Vec::new(),
1422                        };
1423                        self.inner.tx_contexts.write().insert(conn_id, ctx);
1424                        ("begin", format!("BEGIN — xid={xid} (snapshot isolation)"))
1425                    }
1426                    TxnControl::Commit => {
1427                        // SET LOCAL TENANT ends with the transaction.
1428                        self.inner.tx_local_tenants.write().remove(&conn_id);
1429                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
1430                        match ctx {
1431                            Some(ctx) => {
1432                                let mut own_xids = std::collections::HashSet::new();
1433                                own_xids.insert(ctx.xid);
1434                                for (_, sub) in &ctx.savepoints {
1435                                    own_xids.insert(*sub);
1436                                }
1437                                for sub in &ctx.released_sub_xids {
1438                                    own_xids.insert(*sub);
1439                                }
1440                                if let Err(err) = self.check_table_row_write_conflicts(
1441                                    conn_id,
1442                                    &ctx.snapshot,
1443                                    &own_xids,
1444                                    ctx.isolation,
1445                                ) {
1446                                    for (_, sub) in &ctx.savepoints {
1447                                        self.inner.snapshot_manager.rollback(*sub);
1448                                    }
1449                                    for sub in &ctx.released_sub_xids {
1450                                        self.inner.snapshot_manager.rollback(*sub);
1451                                    }
1452                                    self.inner.snapshot_manager.rollback(ctx.xid);
1453                                    self.revive_pending_versioned_updates(conn_id);
1454                                    self.revive_pending_tombstones(conn_id);
1455                                    self.discard_pending_kv_watch_events(conn_id);
1456                                    self.discard_pending_queue_wakes(conn_id);
1457                                    self.discard_pending_store_wal_actions(conn_id);
1458                                    self.release_pending_claim_locks(conn_id);
1459                                    return Err(err);
1460                                }
1461                                self.restore_pending_write_stamps(conn_id);
1462                                if let Err(err) = self.flush_pending_store_wal_actions(conn_id) {
1463                                    for (_, sub) in &ctx.savepoints {
1464                                        self.inner.snapshot_manager.rollback(*sub);
1465                                    }
1466                                    for sub in &ctx.released_sub_xids {
1467                                        self.inner.snapshot_manager.rollback(*sub);
1468                                    }
1469                                    self.inner.snapshot_manager.rollback(ctx.xid);
1470                                    self.revive_pending_versioned_updates(conn_id);
1471                                    self.revive_pending_tombstones(conn_id);
1472                                    self.discard_pending_kv_watch_events(conn_id);
1473                                    self.discard_pending_queue_wakes(conn_id);
1474                                    self.release_pending_claim_locks(conn_id);
1475                                    return Err(err);
1476                                }
1477                                // Phase 2.3.2e: commit every open sub-xid
1478                                // so they also become visible. Their
1479                                // work is promoted to the parent txn's
1480                                // result exactly like a RELEASE would
1481                                // have done.
1482                                for (_, sub) in &ctx.savepoints {
1483                                    self.inner.snapshot_manager.commit(*sub);
1484                                }
1485                                for sub in &ctx.released_sub_xids {
1486                                    self.inner.snapshot_manager.commit(*sub);
1487                                }
1488                                self.inner.snapshot_manager.commit(ctx.xid);
1489                                self.finalize_pending_versioned_updates(conn_id);
1490                                self.finalize_pending_tombstones(conn_id);
1491                                self.finalize_pending_kv_watch_events(conn_id);
1492                                self.finalize_pending_queue_wakes(conn_id);
1493                                self.release_pending_claim_locks(conn_id);
1494                                ("commit", format!("COMMIT — xid={} committed", ctx.xid))
1495                            }
1496                            None => (
1497                                "commit",
1498                                "COMMIT outside transaction — no-op (autocommit)".to_string(),
1499                            ),
1500                        }
1501                    }
1502                    TxnControl::Rollback => {
1503                        self.inner.tx_local_tenants.write().remove(&conn_id);
1504                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
1505                        match ctx {
1506                            Some(ctx) => {
1507                                // Phase 2.3.2e: abort every open sub-xid
1508                                // too so their writes stay hidden.
1509                                for (_, sub) in &ctx.savepoints {
1510                                    self.inner.snapshot_manager.rollback(*sub);
1511                                }
1512                                for sub in &ctx.released_sub_xids {
1513                                    self.inner.snapshot_manager.rollback(*sub);
1514                                }
1515                                self.inner.snapshot_manager.rollback(ctx.xid);
1516                                // Phase 2.3.2b: tuples that the txn had
1517                                // xmax-stamped become live again — wipe xmax
1518                                // back to 0 so later snapshots see them.
1519                                self.revive_pending_versioned_updates(conn_id);
1520                                self.revive_pending_tombstones(conn_id);
1521                                self.discard_pending_kv_watch_events(conn_id);
1522                                self.discard_pending_queue_wakes(conn_id);
1523                                self.discard_pending_store_wal_actions(conn_id);
1524                                self.release_pending_claim_locks(conn_id);
1525                                ("rollback", format!("ROLLBACK — xid={} aborted", ctx.xid))
1526                            }
1527                            None => (
1528                                "rollback",
1529                                "ROLLBACK outside transaction — no-op (autocommit)".to_string(),
1530                            ),
1531                        }
1532                    }
1533                    // Phase 2.3.2e: savepoints map onto sub-xids. Each
1534                    // SAVEPOINT allocates a fresh xid and pushes it
1535                    // onto the per-txn stack so subsequent writes can
1536                    // be selectively rolled back. RELEASE pops without
1537                    // aborting; ROLLBACK TO aborts the sub-xid (and
1538                    // any nested ones) + revives their tombstones.
1539                    TxnControl::Savepoint(name) => {
1540                        let mgr = Arc::clone(&self.inner.snapshot_manager);
1541                        let mut guard = self.inner.tx_contexts.write();
1542                        match guard.get_mut(&conn_id) {
1543                            Some(ctx) => {
1544                                let sub = mgr.begin();
1545                                ctx.savepoints.push((name.clone(), sub));
1546                                ("savepoint", format!("SAVEPOINT {name} — sub_xid={sub}"))
1547                            }
1548                            None => (
1549                                "savepoint",
1550                                "SAVEPOINT outside transaction — no-op".to_string(),
1551                            ),
1552                        }
1553                    }
1554                    TxnControl::ReleaseSavepoint(name) => {
1555                        let mut guard = self.inner.tx_contexts.write();
1556                        match guard.get_mut(&conn_id) {
1557                            Some(ctx) => {
1558                                let pos = ctx
1559                                    .savepoints
1560                                    .iter()
1561                                    .position(|(n, _)| n == name)
1562                                    .ok_or_else(|| {
1563                                        RedDBError::Internal(format!(
1564                                            "savepoint {name} does not exist"
1565                                        ))
1566                                    })?;
1567                                // RELEASE pops the named savepoint and
1568                                // any nested ones. Their sub-xids move
1569                                // to `released_sub_xids` so they commit
1570                                // (or roll back) alongside the parent
1571                                // xid — PG semantics: released
1572                                // savepoints still contribute their
1573                                // work, but their names are gone.
1574                                let released = ctx.savepoints.len() - pos;
1575                                let popped: Vec<Xid> = ctx
1576                                    .savepoints
1577                                    .split_off(pos)
1578                                    .into_iter()
1579                                    .map(|(_, x)| x)
1580                                    .collect();
1581                                ctx.released_sub_xids.extend(popped);
1582                                (
1583                                    "release_savepoint",
1584                                    format!("RELEASE SAVEPOINT {name} — {released} level(s)"),
1585                                )
1586                            }
1587                            None => (
1588                                "release_savepoint",
1589                                "RELEASE outside transaction — no-op".to_string(),
1590                            ),
1591                        }
1592                    }
1593                    TxnControl::RollbackToSavepoint(name) => {
1594                        let mgr = Arc::clone(&self.inner.snapshot_manager);
1595                        // Splice out the savepoint + nested ones under
1596                        // a narrow lock, then run the snapshot-manager
1597                        // + tombstone side-effects without the tx map
1598                        // held so nothing re-enters.
1599                        let drop_result: Option<(Xid, Vec<Xid>)> = {
1600                            let mut guard = self.inner.tx_contexts.write();
1601                            if let Some(ctx) = guard.get_mut(&conn_id) {
1602                                let pos = ctx
1603                                    .savepoints
1604                                    .iter()
1605                                    .position(|(n, _)| n == name)
1606                                    .ok_or_else(|| {
1607                                        RedDBError::Internal(format!(
1608                                            "savepoint {name} does not exist"
1609                                        ))
1610                                    })?;
1611                                let savepoint_xid = ctx.savepoints[pos].1;
1612                                let aborted: Vec<Xid> = ctx
1613                                    .savepoints
1614                                    .split_off(pos)
1615                                    .into_iter()
1616                                    .map(|(_, x)| x)
1617                                    .collect();
1618                                Some((savepoint_xid, aborted))
1619                            } else {
1620                                None
1621                            }
1622                        };
1623
1624                        match drop_result {
1625                            Some((savepoint_xid, aborted)) => {
1626                                for x in &aborted {
1627                                    mgr.rollback(*x);
1628                                }
1629                                let reverted_updates =
1630                                    self.revive_versioned_updates_since(conn_id, savepoint_xid);
1631                                let revived = self.revive_tombstones_since(conn_id, savepoint_xid);
1632                                (
1633                                    "rollback_to_savepoint",
1634                                    format!(
1635                                        "ROLLBACK TO SAVEPOINT {name} — aborted {} sub_xid(s), reverted {reverted_updates} update(s), revived {revived} tombstone(s)",
1636                                        aborted.len(),
1637                                    ),
1638                                )
1639                            }
1640                            None => (
1641                                "rollback_to_savepoint",
1642                                "ROLLBACK TO outside transaction — no-op".to_string(),
1643                            ),
1644                        }
1645                    }
1646                };
1647                Ok(RuntimeQueryResult::ok_message(
1648                    query.to_string(),
1649                    &msg,
1650                    kind,
1651                ))
1652            }
1653            // Schema + Sequence DDL (Phase 1.3 PG parity).
1654            //
1655            // Schemas are lightweight logical namespaces: a CREATE SCHEMA call
1656            // just registers the name in `red_config` under `schema.{name}`.
1657            // Table lookups still happen by collection name; clients using
1658            // `schema.table` qualified names collapse to collection `schema.table`.
1659            //
1660            // Sequences persist a 64-bit counter + metadata (start, increment)
1661            // in `red_config` under `sequence.{name}.*`. Scalar callers
1662            // `nextval('name')` / `currval('name')` arrive with the MVCC phase
1663            // once we have a proper mutating-function dispatch path; for now the
1664            // DDL just establishes the catalog entry so clients don't error.
1665            QueryExpr::CreateSchema(ref q) => {
1666                let store = self.inner.db.store();
1667                let key = format!("schema.{}", q.name);
1668                if store.get_config(&key).is_some() {
1669                    if q.if_not_exists {
1670                        return Ok(RuntimeQueryResult::ok_message(
1671                            query.to_string(),
1672                            &format!("schema {} already exists — skipped", q.name),
1673                            "create_schema",
1674                        ));
1675                    }
1676                    return Err(RedDBError::Internal(format!(
1677                        "schema {} already exists",
1678                        q.name
1679                    )));
1680                }
1681                store.set_config_tree(&key, &crate::serde_json::Value::Bool(true));
1682                Ok(RuntimeQueryResult::ok_message(
1683                    query.to_string(),
1684                    &format!("schema {} created", q.name),
1685                    "create_schema",
1686                ))
1687            }
1688            QueryExpr::DropSchema(ref q) => {
1689                let store = self.inner.db.store();
1690                let key = format!("schema.{}", q.name);
1691                let existed = store.get_config(&key).is_some();
1692                if !existed && !q.if_exists {
1693                    return Err(RedDBError::Internal(format!(
1694                        "schema {} does not exist",
1695                        q.name
1696                    )));
1697                }
1698                // Remove marker from red_config via set to null.
1699                store.set_config_tree(&key, &crate::serde_json::Value::Null);
1700                let suffix = if q.cascade {
1701                    " (CASCADE accepted — tables untouched)"
1702                } else {
1703                    ""
1704                };
1705                Ok(RuntimeQueryResult::ok_message(
1706                    query.to_string(),
1707                    &format!("schema {} dropped{}", q.name, suffix),
1708                    "drop_schema",
1709                ))
1710            }
1711            QueryExpr::CreateSequence(ref q) => {
1712                let store = self.inner.db.store();
1713                let base = format!("sequence.{}", q.name);
1714                let start_key = format!("{base}.start");
1715                let incr_key = format!("{base}.increment");
1716                let curr_key = format!("{base}.current");
1717                if store.get_config(&start_key).is_some() {
1718                    if q.if_not_exists {
1719                        return Ok(RuntimeQueryResult::ok_message(
1720                            query.to_string(),
1721                            &format!("sequence {} already exists — skipped", q.name),
1722                            "create_sequence",
1723                        ));
1724                    }
1725                    return Err(RedDBError::Internal(format!(
1726                        "sequence {} already exists",
1727                        q.name
1728                    )));
1729                }
1730                // Persist start + increment, and set current so the first
1731                // nextval returns `start`.
1732                let initial_current = q.start - q.increment;
1733                store.set_config_tree(
1734                    &start_key,
1735                    &crate::serde_json::Value::Number(q.start as f64),
1736                );
1737                store.set_config_tree(
1738                    &incr_key,
1739                    &crate::serde_json::Value::Number(q.increment as f64),
1740                );
1741                store.set_config_tree(
1742                    &curr_key,
1743                    &crate::serde_json::Value::Number(initial_current as f64),
1744                );
1745                Ok(RuntimeQueryResult::ok_message(
1746                    query.to_string(),
1747                    &format!(
1748                        "sequence {} created (start={}, increment={})",
1749                        q.name, q.start, q.increment
1750                    ),
1751                    "create_sequence",
1752                ))
1753            }
1754            QueryExpr::DropSequence(ref q) => {
1755                let store = self.inner.db.store();
1756                let base = format!("sequence.{}", q.name);
1757                let existed = store.get_config(&format!("{base}.start")).is_some();
1758                if !existed && !q.if_exists {
1759                    return Err(RedDBError::Internal(format!(
1760                        "sequence {} does not exist",
1761                        q.name
1762                    )));
1763                }
1764                for k in ["start", "increment", "current"] {
1765                    store.set_config_tree(&format!("{base}.{k}"), &crate::serde_json::Value::Null);
1766                }
1767                Ok(RuntimeQueryResult::ok_message(
1768                    query.to_string(),
1769                    &format!("sequence {} dropped", q.name),
1770                    "drop_sequence",
1771                ))
1772            }
1773            // Views — CREATE [MATERIALIZED] VIEW (Phase 2.1 PG parity).
1774            //
1775            // The view definition is stored in-memory on RuntimeInner (not
1776            // persisted). SELECTs that reference the view name will substitute
1777            // the stored `QueryExpr` via `resolve_view_reference` during
1778            // planning (same entry point used by table-name resolution).
1779            //
1780            // Materialized views additionally allocate a slot in
1781            // `MaterializedViewCache`; a REFRESH repopulates that slot.
1782            QueryExpr::CreateView(ref q) => {
1783                let mut views = self.inner.views.write();
1784                if views.contains_key(&q.name) && !q.or_replace {
1785                    if q.if_not_exists {
1786                        return Ok(RuntimeQueryResult::ok_message(
1787                            query.to_string(),
1788                            &format!("view {} already exists — skipped", q.name),
1789                            "create_view",
1790                        ));
1791                    }
1792                    return Err(RedDBError::Internal(format!(
1793                        "view {} already exists",
1794                        q.name
1795                    )));
1796                }
1797                views.insert(q.name.clone(), Arc::new(q.clone()));
1798                drop(views);
1799
1800                // Materialized view: register cache slot (data is empty until REFRESH).
1801                if q.materialized {
1802                    use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
1803                    let refresh = match q.refresh_every_ms {
1804                        Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
1805                        None => RefreshPolicy::Manual,
1806                    };
1807                    let dependencies = collect_table_refs(&q.query);
1808                    let def = MaterializedViewDef {
1809                        name: q.name.clone(),
1810                        query: format!("<parsed view {}>", q.name),
1811                        dependencies: dependencies.clone(),
1812                        refresh,
1813                        retention_duration_ms: q.retention_duration_ms,
1814                    };
1815                    self.inner.materialized_views.write().register(def);
1816
1817                    // Issue #593 slice 9a — persist the descriptor to
1818                    // the system catalog so the definition survives a
1819                    // restart. Upsert semantics (delete-then-insert by
1820                    // name) keep the catalog free of duplicate rows
1821                    // across `CREATE OR REPLACE` churn.
1822                    let descriptor =
1823                        crate::runtime::continuous_materialized_view::MaterializedViewDescriptor {
1824                            name: q.name.clone(),
1825                            source_sql: query.to_string(),
1826                            source_collections: dependencies,
1827                            refresh_every_ms: q.refresh_every_ms,
1828                            retention_duration_ms: q.retention_duration_ms,
1829                        };
1830                    let store = self.inner.db.store();
1831                    crate::runtime::continuous_materialized_view::persist_descriptor(
1832                        store.as_ref(),
1833                        &descriptor,
1834                    )?;
1835
1836                    // Issue #594 slice 9b — provision a Table-shaped
1837                    // backing collection named after the view. The
1838                    // rewriter skips materialized views (see
1839                    // `rewrite_view_refs_inner`) so `SELECT FROM v`
1840                    // resolves to this collection directly. Empty
1841                    // until REFRESH wires through it in 9c.
1842                    self.ensure_materialized_view_backing(&q.name)?;
1843                }
1844                // Plan cache may have cached a plan that didn't know about this
1845                // view — invalidate so future references pick up the new binding.
1846                // Result cache gets flushed too: OR REPLACE must not serve a
1847                // prior execution of the obsolete body.
1848                self.invalidate_plan_cache();
1849                self.invalidate_result_cache();
1850
1851                Ok(RuntimeQueryResult::ok_message(
1852                    query.to_string(),
1853                    &format!(
1854                        "{}view {} created",
1855                        if q.materialized { "materialized " } else { "" },
1856                        q.name
1857                    ),
1858                    "create_view",
1859                ))
1860            }
1861            QueryExpr::DropView(ref q) => {
1862                let mut views = self.inner.views.write();
1863                let removed = views.remove(&q.name);
1864                let existed = removed.is_some();
1865                let removed_materialized =
1866                    removed.as_ref().map(|v| v.materialized).unwrap_or(false);
1867                drop(views);
1868                if q.materialized || existed {
1869                    // Try the materialised cache too — silent if absent.
1870                    self.inner.materialized_views.write().remove(&q.name);
1871                    // Issue #593 slice 9a — remove any persisted
1872                    // catalog row. Idempotent: a no-op when the view
1873                    // was never materialized (no row was ever written).
1874                    let store = self.inner.db.store();
1875                    crate::runtime::continuous_materialized_view::remove_by_name(
1876                        store.as_ref(),
1877                        &q.name,
1878                    )?;
1879                }
1880                // Issue #594 slice 9b — drop the backing collection
1881                // that was provisioned at CREATE time. Only mat views
1882                // ever had one; regular views never did.
1883                if removed_materialized || q.materialized {
1884                    self.drop_materialized_view_backing(&q.name)?;
1885                }
1886                // Drop any plan / result cache entries that baked the
1887                // view body into their QueryExpr.
1888                self.invalidate_plan_cache();
1889                self.invalidate_result_cache();
1890                if !existed && !q.if_exists {
1891                    return Err(RedDBError::Internal(format!(
1892                        "view {} does not exist",
1893                        q.name
1894                    )));
1895                }
1896                self.invalidate_plan_cache();
1897                Ok(RuntimeQueryResult::ok_message(
1898                    query.to_string(),
1899                    &format!("view {} dropped", q.name),
1900                    "drop_view",
1901                ))
1902            }
1903            QueryExpr::RefreshMaterializedView(ref q) => {
1904                // Look up the view definition, execute its underlying query,
1905                // and stash the serialized result in the materialised cache.
1906                let view = {
1907                    let views = self.inner.views.read();
1908                    views.get(&q.name).cloned()
1909                };
1910                let view = match view {
1911                    Some(v) => v,
1912                    None => {
1913                        return Err(RedDBError::Internal(format!(
1914                            "view {} does not exist",
1915                            q.name
1916                        )))
1917                    }
1918                };
1919                if !view.materialized {
1920                    return Err(RedDBError::Internal(format!(
1921                        "view {} is not materialized — REFRESH requires \
1922                         CREATE MATERIALIZED VIEW",
1923                        q.name
1924                    )));
1925                }
1926                // Execute the underlying query fresh.
1927                let started = std::time::Instant::now();
1928                let now_ms = std::time::SystemTime::now()
1929                    .duration_since(std::time::UNIX_EPOCH)
1930                    .map(|d| d.as_millis() as u64)
1931                    .unwrap_or(0);
1932                match self.execute_query_expr((*view.query).clone()) {
1933                    Ok(inner_result) => {
1934                        // Issue #595 slice 9c — atomically replace the
1935                        // backing collection's contents under a single
1936                        // WAL group. Concurrent SELECT from the view
1937                        // sees either the prior or new contents, never
1938                        // partial. A crash before the WAL commit lands
1939                        // leaves the prior contents intact on recovery.
1940                        let entities =
1941                            view_records_to_entities(&q.name, &inner_result.result.records);
1942                        let row_count = entities.len() as u64;
1943                        let store = self.inner.db.store();
1944                        let serialized_records = match store.refresh_collection(&q.name, entities) {
1945                            Ok(records) => records,
1946                            Err(err) => {
1947                                let duration_ms = started.elapsed().as_millis() as u64;
1948                                let msg = err.to_string();
1949                                self.inner
1950                                    .materialized_views
1951                                    .write()
1952                                    .record_refresh_failure(
1953                                        &q.name,
1954                                        msg.clone(),
1955                                        duration_ms,
1956                                        now_ms,
1957                                    );
1958                                return Err(RedDBError::Internal(format!(
1959                                    "REFRESH MATERIALIZED VIEW {}: {msg}",
1960                                    q.name
1961                                )));
1962                            }
1963                        };
1964
1965                        // Issue #596 slice 9d — emit a Refresh
1966                        // ChangeRecord into the logical-WAL spool so
1967                        // replicas deterministically replay the same
1968                        // backing-collection contents via
1969                        // `LogicalChangeApplier::apply_record`.
1970                        if let Some(ref primary) = self.inner.db.replication {
1971                            let lsn = self.inner.cdc.emit(
1972                                crate::replication::cdc::ChangeOperation::Refresh,
1973                                &q.name,
1974                                0,
1975                                "refresh",
1976                            );
1977                            self.invalidate_result_cache_for_table(&q.name);
1978                            let timestamp = std::time::SystemTime::now()
1979                                .duration_since(std::time::UNIX_EPOCH)
1980                                .unwrap_or_default()
1981                                .as_millis() as u64;
1982                            let record = ChangeRecord::for_refresh(
1983                                lsn,
1984                                timestamp,
1985                                q.name.clone(),
1986                                serialized_records,
1987                            )
1988                            .with_term(self.current_replication_term());
1989                            let encoded = record.encode();
1990                            primary.append_logical_record(record.lsn, encoded);
1991                        }
1992
1993                        let duration_ms = started.elapsed().as_millis() as u64;
1994                        let serialized = format!("{:?}", inner_result.result);
1995                        self.inner
1996                            .materialized_views
1997                            .write()
1998                            .record_refresh_success(
1999                                &q.name,
2000                                serialized.into_bytes(),
2001                                row_count,
2002                                duration_ms,
2003                                now_ms,
2004                            );
2005                        // SELECT FROM v now reads through the rewriter
2006                        // skip into the backing collection — drop the
2007                        // result cache so prior empty-backing reads
2008                        // don't shadow the new contents.
2009                        self.invalidate_result_cache();
2010                        Ok(RuntimeQueryResult::ok_message(
2011                            query.to_string(),
2012                            &format!("materialized view {} refreshed", q.name),
2013                            "refresh_materialized_view",
2014                        ))
2015                    }
2016                    Err(err) => {
2017                        let duration_ms = started.elapsed().as_millis() as u64;
2018                        let msg = err.to_string();
2019                        self.inner
2020                            .materialized_views
2021                            .write()
2022                            .record_refresh_failure(&q.name, msg.clone(), duration_ms, now_ms);
2023                        Err(err)
2024                    }
2025                }
2026            }
2027            // Row Level Security (Phase 2.5 PG parity).
2028            //
2029            // Policies live in an in-memory registry keyed by (table, name).
2030            // Enforcement (AND-ing the policy's USING clause into every
2031            // query's WHERE for the table) arrives in Phase 2.5.2 via the
2032            // filter compiler; this dispatch only manages the catalog.
2033            QueryExpr::CreatePolicy(ref q) => {
2034                let key = (q.table.clone(), q.name.clone());
2035                self.inner
2036                    .rls_policies
2037                    .write()
2038                    .insert(key, Arc::new(q.clone()));
2039                self.invalidate_plan_cache();
2040                // Issue #120 — surface policy names in the
2041                // schema-vocabulary so AskPipeline (#121) can resolve
2042                // a policy reference back to its table.
2043                self.schema_vocabulary_apply(
2044                    crate::runtime::schema_vocabulary::DdlEvent::CreatePolicy {
2045                        collection: q.table.clone(),
2046                        policy: q.name.clone(),
2047                    },
2048                );
2049                Ok(RuntimeQueryResult::ok_message(
2050                    query.to_string(),
2051                    &format!("policy {} on {} created", q.name, q.table),
2052                    "create_policy",
2053                ))
2054            }
2055            QueryExpr::DropPolicy(ref q) => {
2056                let removed = self
2057                    .inner
2058                    .rls_policies
2059                    .write()
2060                    .remove(&(q.table.clone(), q.name.clone()))
2061                    .is_some();
2062                if !removed && !q.if_exists {
2063                    return Err(RedDBError::Internal(format!(
2064                        "policy {} on {} does not exist",
2065                        q.name, q.table
2066                    )));
2067                }
2068                self.invalidate_plan_cache();
2069                // Issue #120 — keep the schema-vocabulary policy
2070                // entry in sync.
2071                self.schema_vocabulary_apply(
2072                    crate::runtime::schema_vocabulary::DdlEvent::DropPolicy {
2073                        collection: q.table.clone(),
2074                        policy: q.name.clone(),
2075                    },
2076                );
2077                Ok(RuntimeQueryResult::ok_message(
2078                    query.to_string(),
2079                    &format!("policy {} on {} dropped", q.name, q.table),
2080                    "drop_policy",
2081                ))
2082            }
2083            // Foreign Data Wrappers (Phase 3.2 PG parity).
2084            //
2085            // CREATE SERVER / CREATE FOREIGN TABLE register into the shared
2086            // `ForeignTableRegistry`. The read path consults that registry
2087            // before dispatching a SELECT — when the table name matches a
2088            // registered foreign table, we forward the scan to the wrapper
2089            // and skip the normal collection lookup.
2090            //
2091            // Phase 3.2 is in-memory only; persistence across restarts is a
2092            // 3.2.2 follow-up that mirrors the view registry pattern.
2093            QueryExpr::CreateServer(ref q) => {
2094                use crate::storage::fdw::FdwOptions;
2095                let registry = Arc::clone(&self.inner.foreign_tables);
2096                if registry.server(&q.name).is_some() {
2097                    if q.if_not_exists {
2098                        return Ok(RuntimeQueryResult::ok_message(
2099                            query.to_string(),
2100                            &format!("server {} already exists — skipped", q.name),
2101                            "create_server",
2102                        ));
2103                    }
2104                    return Err(RedDBError::Internal(format!(
2105                        "server {} already exists",
2106                        q.name
2107                    )));
2108                }
2109                let mut opts = FdwOptions::new();
2110                for (k, v) in &q.options {
2111                    opts.values.insert(k.clone(), v.clone());
2112                }
2113                registry
2114                    .create_server(&q.name, &q.wrapper, opts)
2115                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2116                Ok(RuntimeQueryResult::ok_message(
2117                    query.to_string(),
2118                    &format!("server {} created (wrapper {})", q.name, q.wrapper),
2119                    "create_server",
2120                ))
2121            }
2122            QueryExpr::DropServer(ref q) => {
2123                let existed = self.inner.foreign_tables.drop_server(&q.name);
2124                if !existed && !q.if_exists {
2125                    return Err(RedDBError::Internal(format!(
2126                        "server {} does not exist",
2127                        q.name
2128                    )));
2129                }
2130                Ok(RuntimeQueryResult::ok_message(
2131                    query.to_string(),
2132                    &format!(
2133                        "server {} dropped{}",
2134                        q.name,
2135                        if q.cascade { " (cascade)" } else { "" }
2136                    ),
2137                    "drop_server",
2138                ))
2139            }
2140            QueryExpr::CreateForeignTable(ref q) => {
2141                use crate::storage::fdw::{FdwOptions, ForeignColumn, ForeignTable};
2142                let registry = Arc::clone(&self.inner.foreign_tables);
2143                if registry.foreign_table(&q.name).is_some() {
2144                    if q.if_not_exists {
2145                        return Ok(RuntimeQueryResult::ok_message(
2146                            query.to_string(),
2147                            &format!("foreign table {} already exists — skipped", q.name),
2148                            "create_foreign_table",
2149                        ));
2150                    }
2151                    return Err(RedDBError::Internal(format!(
2152                        "foreign table {} already exists",
2153                        q.name
2154                    )));
2155                }
2156                let mut opts = FdwOptions::new();
2157                for (k, v) in &q.options {
2158                    opts.values.insert(k.clone(), v.clone());
2159                }
2160                let columns: Vec<ForeignColumn> = q
2161                    .columns
2162                    .iter()
2163                    .map(|c| ForeignColumn {
2164                        name: c.name.clone(),
2165                        data_type: c.data_type.clone(),
2166                        not_null: c.not_null,
2167                    })
2168                    .collect();
2169                registry
2170                    .create_foreign_table(ForeignTable {
2171                        name: q.name.clone(),
2172                        server_name: q.server.clone(),
2173                        columns,
2174                        options: opts,
2175                    })
2176                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2177                self.invalidate_plan_cache();
2178                Ok(RuntimeQueryResult::ok_message(
2179                    query.to_string(),
2180                    &format!("foreign table {} created (server {})", q.name, q.server),
2181                    "create_foreign_table",
2182                ))
2183            }
2184            QueryExpr::DropForeignTable(ref q) => {
2185                let existed = self.inner.foreign_tables.drop_foreign_table(&q.name);
2186                if !existed && !q.if_exists {
2187                    return Err(RedDBError::Internal(format!(
2188                        "foreign table {} does not exist",
2189                        q.name
2190                    )));
2191                }
2192                self.invalidate_plan_cache();
2193                Ok(RuntimeQueryResult::ok_message(
2194                    query.to_string(),
2195                    &format!("foreign table {} dropped", q.name),
2196                    "drop_foreign_table",
2197                ))
2198            }
2199            // COPY table FROM 'path' (Phase 1.5 PG parity).
2200            //
2201            // Stream CSV rows through the shared `CsvImporter`. The collection
2202            // is auto-created on first insert (via `insert_auto`-style path);
2203            // VACUUM/ANALYZE afterwards is up to the caller.
2204            QueryExpr::CopyFrom(ref q) => {
2205                use crate::storage::import::{CsvConfig, CsvImporter};
2206                let store = self.inner.db.store();
2207                let cfg = CsvConfig {
2208                    collection: q.table.clone(),
2209                    has_header: q.has_header,
2210                    delimiter: q.delimiter.map(|c| c as u8).unwrap_or(b','),
2211                    ..CsvConfig::default()
2212                };
2213                let importer = CsvImporter::new(cfg);
2214                let stats = importer
2215                    .import_file(&q.path, store.as_ref())
2216                    .map_err(|e| RedDBError::Internal(format!("COPY failed: {e}")))?;
2217                // Tables are written → invalidate cached plans / result cache.
2218                self.note_table_write(&q.table);
2219                Ok(RuntimeQueryResult::ok_message(
2220                    query.to_string(),
2221                    &format!(
2222                        "COPY imported {} rows into {} ({} errors skipped, {}ms)",
2223                        stats.records_imported, q.table, stats.errors_skipped, stats.duration_ms
2224                    ),
2225                    "copy_from",
2226                ))
2227            }
2228            // Maintenance commands (Phase 1.2 PG parity).
2229            //
2230            // - VACUUM [FULL] [table]: refreshes planner stats for the target
2231            //   collection(s) and — when FULL — triggers a full pager persist
2232            //   (flushes dirty pages + fsync). Also invalidates the result cache
2233            //   so subsequent reads re-execute against the freshly compacted
2234            //   storage. RedDB's segment/btree GC runs continuously via the
2235            //   background lifecycle; explicit space reclamation for sealed
2236            //   segments arrives with Phase 2.3 (MVCC + dead-tuple reclamation).
2237            // - ANALYZE [table]: reruns `analyze_collection` +
2238            //   `persist_table_stats` via `refresh_table_planner_stats` so the
2239            //   planner has fresh histograms, distinct estimates, null counts.
2240            //
2241            // Both commands accept an optional target; omitting the target
2242            // iterates every collection in the store.
2243            QueryExpr::MaintenanceCommand(ref cmd) => {
2244                use crate::storage::query::ast::MaintenanceCommand as Mc;
2245                let store = self.inner.db.store();
2246                let (kind, msg) = match cmd {
2247                    Mc::Analyze { target } => {
2248                        let targets: Vec<String> = match target {
2249                            Some(t) => vec![t.clone()],
2250                            None => store.list_collections(),
2251                        };
2252                        for t in &targets {
2253                            self.refresh_table_planner_stats(t);
2254                        }
2255                        (
2256                            "analyze",
2257                            format!("ANALYZE refreshed stats for {} table(s)", targets.len()),
2258                        )
2259                    }
2260                    Mc::Vacuum { target, full } => {
2261                        let targets: Vec<String> = match target {
2262                            Some(t) => vec![t.clone()],
2263                            None => store.list_collections(),
2264                        };
2265                        let cutoff_xid = self.mvcc_vacuum_cutoff_xid();
2266                        let mut vacuum_stats =
2267                            crate::storage::unified::store::MvccVacuumStats::default();
2268                        for t in &targets {
2269                            let stats = store.vacuum_mvcc_history(t, cutoff_xid).map_err(|e| {
2270                                RedDBError::Internal(format!(
2271                                    "VACUUM MVCC history failed for {t}: {e}"
2272                                ))
2273                            })?;
2274                            if stats.reclaimed_versions > 0 {
2275                                self.rebuild_runtime_indexes_for_table(t)?;
2276                            }
2277                            vacuum_stats.add(&stats);
2278                        }
2279                        self.inner.snapshot_manager.prune_aborted(cutoff_xid);
2280                        // Stats refresh covers every target (same as ANALYZE).
2281                        for t in &targets {
2282                            self.refresh_table_planner_stats(t);
2283                        }
2284                        // FULL forces a pager persist (dirty-page flush + fsync).
2285                        // Regular VACUUM relies on the background writer / segment
2286                        // lifecycle so the command is non-blocking.
2287                        let persisted = if *full {
2288                            match store.persist() {
2289                                Ok(()) => true,
2290                                Err(e) => {
2291                                    return Err(RedDBError::Internal(format!(
2292                                        "VACUUM FULL persist failed: {e:?}"
2293                                    )));
2294                                }
2295                            }
2296                        } else {
2297                            false
2298                        };
2299                        // Result cache depended on pre-vacuum state.
2300                        self.invalidate_result_cache();
2301                        (
2302                            "vacuum",
2303                            format!(
2304                                "VACUUM{} processed {} table(s): scanned_versions={}, retained_versions={}, reclaimed_versions={}, retained_history_versions={}, reclaimed_history_versions={}, retained_tombstones={}, reclaimed_tombstones={}{}",
2305                                if *full { " FULL" } else { "" },
2306                                targets.len(),
2307                                vacuum_stats.scanned_versions,
2308                                vacuum_stats.retained_versions,
2309                                vacuum_stats.reclaimed_versions,
2310                                vacuum_stats.retained_history_versions,
2311                                vacuum_stats.reclaimed_history_versions,
2312                                vacuum_stats.retained_tombstones,
2313                                vacuum_stats.reclaimed_tombstones,
2314                                if persisted {
2315                                    " (pages flushed to disk)"
2316                                } else {
2317                                    ""
2318                                }
2319                            ),
2320                        )
2321                    }
2322                };
2323                Ok(RuntimeQueryResult::ok_message(
2324                    query.to_string(),
2325                    &msg,
2326                    kind,
2327                ))
2328            }
2329            // GRANT / REVOKE / ALTER USER (RBAC milestone).
2330            //
2331            // These hit the AuthStore directly. The statement frame /
2332            // privilege gate has already decided whether the caller may
2333            // even run the statement; here we just translate the AST into
2334            // AuthStore calls.
2335            QueryExpr::Grant(ref g) => self.execute_grant_statement(query, g),
2336            QueryExpr::Revoke(ref r) => self.execute_revoke_statement(query, r),
2337            QueryExpr::AlterUser(ref a) => self.execute_alter_user_statement(query, a),
2338            QueryExpr::CreateUser(ref u) => self.execute_create_user_statement(query, u),
2339            QueryExpr::CreateIamPolicy { ref id, ref json } => {
2340                self.execute_create_iam_policy(query, id, json)
2341            }
2342            QueryExpr::DropIamPolicy { ref id } => self.execute_drop_iam_policy(query, id),
2343            QueryExpr::AttachPolicy {
2344                ref policy_id,
2345                ref principal,
2346            } => self.execute_attach_policy(query, policy_id, principal),
2347            QueryExpr::DetachPolicy {
2348                ref policy_id,
2349                ref principal,
2350            } => self.execute_detach_policy(query, policy_id, principal),
2351            QueryExpr::ShowPolicies { ref filter } => {
2352                self.execute_show_policies(query, filter.as_ref())
2353            }
2354            QueryExpr::ShowEffectivePermissions {
2355                ref user,
2356                ref resource,
2357            } => self.execute_show_effective_permissions(query, user, resource.as_ref()),
2358            QueryExpr::SimulatePolicy {
2359                ref user,
2360                ref action,
2361                ref resource,
2362            } => self.execute_simulate_policy(query, user, action, resource),
2363            QueryExpr::LintPolicy { ref source } => self.execute_lint_policy(query, source),
2364            QueryExpr::MigratePolicyMode {
2365                ref target,
2366                dry_run,
2367            } => self.execute_migrate_policy_mode(query, target, dry_run),
2368            QueryExpr::CreateMigration(ref q) => self.execute_create_migration(query, q),
2369            QueryExpr::ApplyMigration(ref q) => self.execute_apply_migration(query, q),
2370            QueryExpr::RollbackMigration(ref q) => self.execute_rollback_migration(query, q),
2371            QueryExpr::ExplainMigration(ref q) => self.execute_explain_migration(query, q),
2372            _ => Err(RedDBError::Query(
2373                "unsupported command in runtime dispatcher".to_string(),
2374            )),
2375        };
2376
2377        if !control_event_specs.is_empty() {
2378            let (outcome, reason) = match &query_result {
2379                Ok(_) => (crate::runtime::control_events::Outcome::Allowed, None),
2380                Err(err) => (control_event_outcome_for_error(err), Some(err.to_string())),
2381            };
2382            for spec in &control_event_specs {
2383                self.emit_control_event(
2384                    spec.kind,
2385                    outcome,
2386                    spec.action,
2387                    spec.resource.clone(),
2388                    reason.clone(),
2389                    spec.fields.clone(),
2390                )?;
2391            }
2392        }
2393
2394        if let (Some(plan), Ok(result)) = (&query_audit_plan, &query_result) {
2395            self.emit_query_audit(
2396                query,
2397                plan,
2398                query_audit_started.elapsed().as_millis() as u64,
2399                result,
2400            );
2401        }
2402
2403        // Decrypt Value::Secret columns in-place before caching, so
2404        // cached results match the post-decrypt shape and repeat
2405        // queries skip the per-row AES-GCM pass.
2406        let mut query_result = query_result;
2407        if let Ok(ref mut result) = query_result {
2408            if result.statement_type == "select" {
2409                self.apply_secret_decryption(result);
2410            }
2411        }
2412
2413        // Cache SELECT results for 30s.
2414        // Skip: pre-serialized JSON (large clone), and result sets > 5 rows.
2415        // Large multi-row results (range scans, filtered scans) are rarely
2416        // repeated with the same literal values so the cache hit rate is near
2417        // zero while the clone cost (100 records × ~16 fields each) is high.
2418        // Aggregations (1 row) and point lookups (1 row) still benefit.
2419        if let Ok(ref result) = query_result {
2420            frame.write_result_cache(self, result, result_cache_scopes);
2421        }
2422
2423        query_result
2424    }
2425
2426    /// Execute a pre-parsed `QueryExpr` directly, bypassing SQL parsing and the
2427    /// plan cache. Used by the prepared-statement fast path so that `execute_prepared`
2428    /// calls pay zero parse + cache overhead.
2429    ///
2430    /// Applies secret decryption on SELECT results, identical to `execute_query`.
2431    pub fn execute_query_expr(&self, expr: QueryExpr) -> RedDBResult<RuntimeQueryResult> {
2432        let _config_snapshot_guard = ConfigSnapshotGuard::install(Arc::clone(&self.inner.db));
2433        let _secret_store_guard = SecretStoreGuard::install(self.inner.auth_store.read().clone());
2434        // View rewrite (Phase 2.1): substitute any `QueryExpr::Table(tq)`
2435        // whose `tq.table` matches a registered view with the view's
2436        // underlying query. Safe to call even when no views are registered.
2437        let expr = self.rewrite_view_refs(expr);
2438
2439        self.validate_model_operations_before_auth(&expr)?;
2440        // Granular RBAC privilege check. Runs before dispatch so a
2441        // denied caller never reaches storage. Fail-closed: any error
2442        // resolving the action / resource produces PermissionDenied.
2443        if let Err(err) = self.check_query_privilege(&expr) {
2444            return Err(RedDBError::Query(format!("permission denied: {err}")));
2445        }
2446
2447        let statement = query_expr_name(&expr);
2448        let mode = detect_mode(statement);
2449        let query_str = statement;
2450
2451        let result = self.dispatch_expr(expr, query_str, mode)?;
2452        let mut r = result;
2453        if r.statement_type == "select" {
2454            self.apply_secret_decryption(&mut r);
2455        }
2456        Ok(r)
2457    }
2458
2459    pub(super) fn validate_model_operations_before_auth(
2460        &self,
2461        expr: &QueryExpr,
2462    ) -> RedDBResult<()> {
2463        use crate::catalog::CollectionModel;
2464        use crate::runtime::ddl::polymorphic_resolver;
2465        use crate::storage::query::ast::KvCommand;
2466
2467        let system_schema_target = match expr {
2468            QueryExpr::DropTable(q) => Some(q.name.as_str()),
2469            QueryExpr::DropGraph(q) => Some(q.name.as_str()),
2470            QueryExpr::DropVector(q) => Some(q.name.as_str()),
2471            QueryExpr::DropDocument(q) => Some(q.name.as_str()),
2472            QueryExpr::DropKv(q) => Some(q.name.as_str()),
2473            QueryExpr::DropCollection(q) => Some(q.name.as_str()),
2474            QueryExpr::Truncate(q) => Some(q.name.as_str()),
2475            _ => None,
2476        };
2477        if system_schema_target.is_some_and(crate::runtime::impl_ddl::is_system_schema_name) {
2478            return Err(RedDBError::Query("system schema is read-only".to_string()));
2479        }
2480
2481        let expected = match expr {
2482            QueryExpr::DropTable(q) => Some((q.name.as_str(), CollectionModel::Table)),
2483            QueryExpr::DropGraph(q) => Some((q.name.as_str(), CollectionModel::Graph)),
2484            QueryExpr::DropVector(q) => Some((q.name.as_str(), CollectionModel::Vector)),
2485            QueryExpr::DropDocument(q) => Some((q.name.as_str(), CollectionModel::Document)),
2486            QueryExpr::DropKv(q) => Some((q.name.as_str(), q.model)),
2487            QueryExpr::DropCollection(q) => q.model.map(|model| (q.name.as_str(), model)),
2488            QueryExpr::Truncate(q) => q.model.map(|model| (q.name.as_str(), model)),
2489            QueryExpr::KvCommand(cmd) => {
2490                let (collection, model) = match cmd {
2491                    KvCommand::Put {
2492                        collection, model, ..
2493                    }
2494                    | KvCommand::Get {
2495                        collection, model, ..
2496                    }
2497                    | KvCommand::Incr {
2498                        collection, model, ..
2499                    }
2500                    | KvCommand::Cas {
2501                        collection, model, ..
2502                    }
2503                    | KvCommand::List {
2504                        collection, model, ..
2505                    }
2506                    | KvCommand::Delete {
2507                        collection, model, ..
2508                    } => (collection.as_str(), *model),
2509                    KvCommand::Rotate { collection, .. }
2510                    | KvCommand::History { collection, .. }
2511                    | KvCommand::Purge { collection, .. } => {
2512                        (collection.as_str(), CollectionModel::Vault)
2513                    }
2514                    KvCommand::InvalidateTags { collection, .. } => {
2515                        (collection.as_str(), CollectionModel::Kv)
2516                    }
2517                    KvCommand::Watch {
2518                        collection, model, ..
2519                    } => (collection.as_str(), *model),
2520                    KvCommand::Unseal { collection, .. } => {
2521                        (collection.as_str(), CollectionModel::Vault)
2522                    }
2523                };
2524                Some((collection, model))
2525            }
2526            QueryExpr::ConfigCommand(cmd) => {
2527                self.validate_config_command_before_auth(cmd)?;
2528                None
2529            }
2530            _ => None,
2531        };
2532
2533        let Some((name, expected_model)) = expected else {
2534            return Ok(());
2535        };
2536        let snapshot = self.inner.db.catalog_model_snapshot();
2537        let Some(actual_model) = snapshot
2538            .collections
2539            .iter()
2540            .find(|collection| collection.name == name)
2541            .map(|collection| collection.declared_model.unwrap_or(collection.model))
2542        else {
2543            return Ok(());
2544        };
2545        polymorphic_resolver::ensure_model_match(expected_model, actual_model)
2546    }
2547
2548    /// Walk a `QueryExpr` and replace `QueryExpr::Table(tq)` nodes whose
2549    /// `tq.table` matches a registered view name with the view's stored
2550    /// body. Recurses through joins so `SELECT ... FROM t JOIN myview ...`
2551    /// resolves correctly. Pure operation — no side effects.
2552    pub(super) fn rewrite_view_refs(&self, expr: QueryExpr) -> QueryExpr {
2553        // Fast path: no views registered → return original expression.
2554        if self.inner.views.read().is_empty() {
2555            return expr;
2556        }
2557        self.rewrite_view_refs_inner(expr)
2558    }
2559
2560    fn rewrite_view_refs_inner(&self, expr: QueryExpr) -> QueryExpr {
2561        use crate::storage::query::ast::{Filter, TableSource};
2562        match expr {
2563            QueryExpr::Table(mut tq) => {
2564                // 1. If the TableSource is a subquery, recurse into it so
2565                //    `SELECT ... FROM (SELECT ... FROM myview) t` expands.
2566                //    The legacy `table` field (set to a synthetic
2567                //    "__subq_NNNN" sentinel) stays as-is so callers that
2568                //    read it keep compiling.
2569                if let Some(TableSource::Subquery(body)) = tq.source.take() {
2570                    tq.source = Some(TableSource::Subquery(Box::new(
2571                        self.rewrite_view_refs_inner(*body),
2572                    )));
2573                    return QueryExpr::Table(tq);
2574                }
2575
2576                // 2. Restore the source field (took it above for match).
2577                // When the source was `None` or `TableSource::Name(_)`, the
2578                // real lookup key is `tq.table` — check the view registry.
2579                let maybe_view = {
2580                    let views = self.inner.views.read();
2581                    views.get(&tq.table).cloned()
2582                };
2583                let Some(view) = maybe_view else {
2584                    return QueryExpr::Table(tq);
2585                };
2586
2587                // Issue #594 slice 9b — materialized views are read
2588                // from their backing collection, not by substituting
2589                // the body. Returning the TableQuery as-is lets the
2590                // normal table-read path resolve `SELECT FROM v`
2591                // against the collection provisioned at CREATE time.
2592                if view.materialized {
2593                    return QueryExpr::Table(tq);
2594                }
2595
2596                // Recurse into the view body — views may reference other
2597                // views. The recursion yields the final QueryExpr we need
2598                // to merge the outer's filter / limit / offset into.
2599                let inner_expr = self.rewrite_view_refs_inner((*view.query).clone());
2600
2601                // Phase 5: when the body is a Table we merge the outer
2602                // TableQuery's WHERE / LIMIT / OFFSET into it so stacked
2603                // views filter recursively. Non-table bodies (Search,
2604                // Ask, Vector, Graph, Hybrid) can't meaningfully combine
2605                // with an outer Table query today — return the body
2606                // verbatim; outer predicates are lost. Full projection
2607                // merge lands in Phase 5.2.
2608                match inner_expr {
2609                    QueryExpr::Table(mut inner_tq) => {
2610                        if let Some(outer_filter) = tq.filter.take() {
2611                            inner_tq.filter = Some(match inner_tq.filter.take() {
2612                                Some(existing) => {
2613                                    Filter::And(Box::new(existing), Box::new(outer_filter))
2614                                }
2615                                None => outer_filter,
2616                            });
2617                            // Keep the `Expr` form in lock-step with the
2618                            // merged `Filter`. The executor prefers
2619                            // `where_expr` and nulls `filter` when it is
2620                            // present (see `execute_query_inner`), so a
2621                            // stacked view whose outer predicate was only
2622                            // merged into `filter` would silently drop that
2623                            // predicate at eval time (#635).
2624                            inner_tq.where_expr = inner_tq
2625                                .filter
2626                                .as_ref()
2627                                .map(crate::storage::query::sql_lowering::filter_to_expr);
2628                        }
2629                        if let Some(outer_limit) = tq.limit {
2630                            inner_tq.limit = Some(match inner_tq.limit {
2631                                Some(existing) => existing.min(outer_limit),
2632                                None => outer_limit,
2633                            });
2634                        }
2635                        if let Some(outer_offset) = tq.offset {
2636                            inner_tq.offset = Some(match inner_tq.offset {
2637                                Some(existing) => existing + outer_offset,
2638                                None => outer_offset,
2639                            });
2640                        }
2641                        QueryExpr::Table(inner_tq)
2642                    }
2643                    other => other,
2644                }
2645            }
2646            QueryExpr::Join(mut jq) => {
2647                jq.left = Box::new(self.rewrite_view_refs_inner(*jq.left));
2648                jq.right = Box::new(self.rewrite_view_refs_inner(*jq.right));
2649                QueryExpr::Join(jq)
2650            }
2651            // Other variants don't carry nested QueryExpr that can reference
2652            // a view by table name. Return as-is.
2653            other => other,
2654        }
2655    }
2656
2657    fn resolve_table_expr_subqueries(
2658        &self,
2659        mut table: TableQuery,
2660        frame: &dyn super::statement_frame::ReadFrame,
2661    ) -> RedDBResult<TableQuery> {
2662        // Only a `Subquery` source needs recursive resolution. `.take()`
2663        // would otherwise drop a `Name` / `Function` source on the floor
2664        // (the `if let` skips the body but the take already cleared it),
2665        // which silently broke `SELECT * FROM components(g)` — the TVF
2666        // dispatch downstream keys off `TableSource::Function` and never
2667        // fired. Restore any non-subquery source unchanged (issue #795).
2668        match table.source.take() {
2669            Some(TableSource::Subquery(inner)) => {
2670                let inner = self.resolve_select_expr_subqueries(*inner, frame)?;
2671                table.source = Some(TableSource::Subquery(Box::new(inner)));
2672            }
2673            other => table.source = other,
2674        }
2675
2676        let outer_scopes = relation_scopes_for_query(&QueryExpr::Table(table.clone()));
2677        for item in &mut table.select_items {
2678            if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
2679                *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2680            }
2681        }
2682        if let Some(where_expr) = table.where_expr.take() {
2683            table.where_expr =
2684                Some(self.resolve_expr_subqueries(where_expr, &outer_scopes, frame)?);
2685            table.filter = None;
2686        }
2687        if let Some(having_expr) = table.having_expr.take() {
2688            table.having_expr =
2689                Some(self.resolve_expr_subqueries(having_expr, &outer_scopes, frame)?);
2690            table.having = None;
2691        }
2692        for expr in &mut table.group_by_exprs {
2693            *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2694        }
2695        for clause in &mut table.order_by {
2696            if let Some(expr) = clause.expr.take() {
2697                clause.expr = Some(self.resolve_expr_subqueries(expr, &outer_scopes, frame)?);
2698            }
2699        }
2700        Ok(table)
2701    }
2702
2703    fn resolve_select_expr_subqueries(
2704        &self,
2705        expr: QueryExpr,
2706        frame: &dyn super::statement_frame::ReadFrame,
2707    ) -> RedDBResult<QueryExpr> {
2708        match expr {
2709            QueryExpr::Table(table) => self
2710                .resolve_table_expr_subqueries(table, frame)
2711                .map(QueryExpr::Table),
2712            QueryExpr::Join(mut join) => {
2713                join.left = Box::new(self.resolve_select_expr_subqueries(*join.left, frame)?);
2714                join.right = Box::new(self.resolve_select_expr_subqueries(*join.right, frame)?);
2715                Ok(QueryExpr::Join(join))
2716            }
2717            other => Ok(other),
2718        }
2719    }
2720
2721    fn resolve_expr_subqueries(
2722        &self,
2723        expr: crate::storage::query::ast::Expr,
2724        outer_scopes: &[String],
2725        frame: &dyn super::statement_frame::ReadFrame,
2726    ) -> RedDBResult<crate::storage::query::ast::Expr> {
2727        use crate::storage::query::ast::Expr;
2728
2729        match expr {
2730            Expr::Subquery { query, span } => {
2731                let values = self.execute_expr_subquery_values(query, outer_scopes, frame)?;
2732                if values.len() > 1 {
2733                    return Err(RedDBError::Query(
2734                        "scalar subquery returned more than one row".to_string(),
2735                    ));
2736                }
2737                Ok(Expr::Literal {
2738                    value: values.into_iter().next().unwrap_or(Value::Null),
2739                    span,
2740                })
2741            }
2742            Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
2743                op,
2744                lhs: Box::new(self.resolve_expr_subqueries(*lhs, outer_scopes, frame)?),
2745                rhs: Box::new(self.resolve_expr_subqueries(*rhs, outer_scopes, frame)?),
2746                span,
2747            }),
2748            Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
2749                op,
2750                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2751                span,
2752            }),
2753            Expr::Cast {
2754                inner,
2755                target,
2756                span,
2757            } => Ok(Expr::Cast {
2758                inner: Box::new(self.resolve_expr_subqueries(*inner, outer_scopes, frame)?),
2759                target,
2760                span,
2761            }),
2762            Expr::FunctionCall { name, args, span } => {
2763                let args = args
2764                    .into_iter()
2765                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2766                    .collect::<RedDBResult<Vec<_>>>()?;
2767                Ok(Expr::FunctionCall { name, args, span })
2768            }
2769            Expr::Case {
2770                branches,
2771                else_,
2772                span,
2773            } => {
2774                let branches = branches
2775                    .into_iter()
2776                    .map(|(cond, value)| {
2777                        Ok((
2778                            self.resolve_expr_subqueries(cond, outer_scopes, frame)?,
2779                            self.resolve_expr_subqueries(value, outer_scopes, frame)?,
2780                        ))
2781                    })
2782                    .collect::<RedDBResult<Vec<_>>>()?;
2783                let else_ = else_
2784                    .map(|expr| self.resolve_expr_subqueries(*expr, outer_scopes, frame))
2785                    .transpose()?
2786                    .map(Box::new);
2787                Ok(Expr::Case {
2788                    branches,
2789                    else_,
2790                    span,
2791                })
2792            }
2793            Expr::IsNull {
2794                operand,
2795                negated,
2796                span,
2797            } => Ok(Expr::IsNull {
2798                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2799                negated,
2800                span,
2801            }),
2802            Expr::InList {
2803                target,
2804                values,
2805                negated,
2806                span,
2807            } => {
2808                let target =
2809                    Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?);
2810                let mut resolved = Vec::new();
2811                for value in values {
2812                    if let Expr::Subquery { query, .. } = value {
2813                        resolved.extend(
2814                            self.execute_expr_subquery_values(query, outer_scopes, frame)?
2815                                .into_iter()
2816                                .map(Expr::lit),
2817                        );
2818                    } else {
2819                        resolved.push(self.resolve_expr_subqueries(value, outer_scopes, frame)?);
2820                    }
2821                }
2822                Ok(Expr::InList {
2823                    target,
2824                    values: resolved,
2825                    negated,
2826                    span,
2827                })
2828            }
2829            Expr::Between {
2830                target,
2831                low,
2832                high,
2833                negated,
2834                span,
2835            } => Ok(Expr::Between {
2836                target: Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?),
2837                low: Box::new(self.resolve_expr_subqueries(*low, outer_scopes, frame)?),
2838                high: Box::new(self.resolve_expr_subqueries(*high, outer_scopes, frame)?),
2839                negated,
2840                span,
2841            }),
2842            other => Ok(other),
2843        }
2844    }
2845
2846    fn execute_expr_subquery_values(
2847        &self,
2848        subquery: crate::storage::query::ast::ExprSubquery,
2849        outer_scopes: &[String],
2850        frame: &dyn super::statement_frame::ReadFrame,
2851    ) -> RedDBResult<Vec<Value>> {
2852        let query = *subquery.query;
2853        if query_references_outer_scope(&query, outer_scopes) {
2854            return Err(RedDBError::Query(
2855                "NOT_YET_SUPPORTED: correlated subqueries are not supported yet; track follow-up issue #470-correlated-subqueries".to_string(),
2856            ));
2857        }
2858        let query = self.rewrite_view_refs(query);
2859        let query = self.resolve_select_expr_subqueries(query, frame)?;
2860        let query = self.authorize_relational_select_expr(query, frame)?;
2861        let result = match query {
2862            QueryExpr::Table(table) => {
2863                execute_runtime_table_query(&self.inner.db, &table, Some(&self.inner.index_store))?
2864            }
2865            QueryExpr::Join(join) => execute_runtime_join_query(&self.inner.db, &join)?,
2866            other => {
2867                return Err(RedDBError::Query(format!(
2868                    "expression subquery must be a SELECT query, got {}",
2869                    query_expr_name(&other)
2870                )))
2871            }
2872        };
2873        first_column_values(result)
2874    }
2875
2876    fn dispatch_expr(
2877        &self,
2878        expr: QueryExpr,
2879        query_str: &str,
2880        mode: QueryMode,
2881    ) -> RedDBResult<RuntimeQueryResult> {
2882        let statement = query_expr_name(&expr);
2883        match expr {
2884            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
2885                // Graph queries are not cacheable as prepared statements.
2886                Err(RedDBError::Query(
2887                    "graph queries cannot be used as prepared statements".to_string(),
2888                ))
2889            }
2890            QueryExpr::Table(table) => {
2891                let scope = self.ai_scope();
2892                let table = self.resolve_table_expr_subqueries(
2893                    table,
2894                    &scope as &dyn super::statement_frame::ReadFrame,
2895                )?;
2896                // Table-valued functions (e.g. components(g)) dispatch to a
2897                // read-only executor before any catalog/virtual-table routing
2898                // (issue #795).
2899                if let Some(TableSource::Function {
2900                    name,
2901                    args,
2902                    named_args,
2903                }) = table.source.clone()
2904                {
2905                    return Ok(RuntimeQueryResult {
2906                        query: query_str.to_string(),
2907                        mode,
2908                        statement,
2909                        engine: "runtime-graph-tvf",
2910                        result: self.execute_table_function(&name, &args, &named_args)?,
2911                        affected_rows: 0,
2912                        statement_type: "select",
2913                        bookmark: None,
2914                    });
2915                }
2916                // Inline-graph TVF (issue #799) on the prepared-statement /
2917                // direct-expr path. Result caching is wired on the
2918                // `execute_query_inner` path; here we just compute and return.
2919                if let Some(TableSource::InlineGraphFunction {
2920                    name,
2921                    nodes,
2922                    edges,
2923                    named_args,
2924                }) = table.source.clone()
2925                {
2926                    return Ok(RuntimeQueryResult {
2927                        query: query_str.to_string(),
2928                        mode,
2929                        statement,
2930                        engine: "runtime-graph-tvf-inline",
2931                        result: self.execute_inline_graph_function(
2932                            &name,
2933                            &nodes,
2934                            &edges,
2935                            &named_args,
2936                        )?,
2937                        affected_rows: 0,
2938                        statement_type: "select",
2939                        bookmark: None,
2940                    });
2941                }
2942                if super::red_schema::is_virtual_table(&table.table) {
2943                    return Ok(RuntimeQueryResult {
2944                        query: query_str.to_string(),
2945                        mode,
2946                        statement,
2947                        engine: "runtime-red-schema",
2948                        result: super::red_schema::red_query(
2949                            self,
2950                            &table.table,
2951                            &table,
2952                            &scope as &dyn super::statement_frame::ReadFrame,
2953                        )?,
2954                        affected_rows: 0,
2955                        statement_type: "select",
2956                        bookmark: None,
2957                    });
2958                }
2959                // `<graph>.<output>` analytics virtual view (issue #800).
2960                if let Some(view_result) = self.try_resolve_analytics_view(
2961                    &table,
2962                    &scope as &dyn super::statement_frame::ReadFrame,
2963                )? {
2964                    return Ok(RuntimeQueryResult {
2965                        query: query_str.to_string(),
2966                        mode,
2967                        statement,
2968                        engine: "runtime-graph-analytics-view",
2969                        result: view_result,
2970                        affected_rows: 0,
2971                        statement_type: "select",
2972                        bookmark: None,
2973                    });
2974                }
2975                let Some(table_with_rls) = self.authorize_relational_table_select(
2976                    table,
2977                    &scope as &dyn super::statement_frame::ReadFrame,
2978                )?
2979                else {
2980                    return Ok(RuntimeQueryResult {
2981                        query: query_str.to_string(),
2982                        mode,
2983                        statement,
2984                        engine: "runtime-table-rls",
2985                        result: crate::storage::query::unified::UnifiedResult::empty(),
2986                        affected_rows: 0,
2987                        statement_type: "select",
2988                        bookmark: None,
2989                    });
2990                };
2991                Ok(RuntimeQueryResult {
2992                    query: query_str.to_string(),
2993                    mode,
2994                    statement,
2995                    engine: "runtime-table",
2996                    result: execute_runtime_table_query(
2997                        &self.inner.db,
2998                        &table_with_rls,
2999                        Some(&self.inner.index_store),
3000                    )?,
3001                    affected_rows: 0,
3002                    statement_type: "select",
3003                    bookmark: None,
3004                })
3005            }
3006            QueryExpr::Join(join) => {
3007                let scope = self.ai_scope();
3008                let Some(join_with_rls) = self.authorize_relational_join_select(
3009                    join,
3010                    &scope as &dyn super::statement_frame::ReadFrame,
3011                )?
3012                else {
3013                    return Ok(RuntimeQueryResult {
3014                        query: query_str.to_string(),
3015                        mode,
3016                        statement,
3017                        engine: "runtime-join-rls",
3018                        result: crate::storage::query::unified::UnifiedResult::empty(),
3019                        affected_rows: 0,
3020                        statement_type: "select",
3021                        bookmark: None,
3022                    });
3023                };
3024                Ok(RuntimeQueryResult {
3025                    query: query_str.to_string(),
3026                    mode,
3027                    statement,
3028                    engine: "runtime-join",
3029                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
3030                    affected_rows: 0,
3031                    statement_type: "select",
3032                    bookmark: None,
3033                })
3034            }
3035            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
3036                query: query_str.to_string(),
3037                mode,
3038                statement,
3039                engine: "runtime-vector",
3040                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
3041                affected_rows: 0,
3042                statement_type: "select",
3043                bookmark: None,
3044            }),
3045            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
3046                query: query_str.to_string(),
3047                mode,
3048                statement,
3049                engine: "runtime-hybrid",
3050                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
3051                affected_rows: 0,
3052                statement_type: "select",
3053                bookmark: None,
3054            }),
3055            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
3056                Err(RedDBError::Query(
3057                    super::red_schema::READ_ONLY_ERROR.to_string(),
3058                ))
3059            }
3060            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
3061                Err(RedDBError::Query(
3062                    super::red_schema::READ_ONLY_ERROR.to_string(),
3063                ))
3064            }
3065            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
3066                Err(RedDBError::Query(
3067                    super::red_schema::READ_ONLY_ERROR.to_string(),
3068                ))
3069            }
3070            QueryExpr::Insert(ref insert) => self
3071                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
3072                    self.execute_insert(query_str, insert)
3073                }),
3074            QueryExpr::Update(ref update) => self
3075                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
3076                    self.execute_update(query_str, update)
3077                }),
3078            QueryExpr::Delete(ref delete) => self
3079                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
3080                    self.execute_delete(query_str, delete)
3081                }),
3082            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query_str, cmd),
3083            QueryExpr::Ask(ref ask) => self.execute_ask(query_str, ask),
3084            _ => Err(RedDBError::Query(format!(
3085                "prepared-statement execution does not support {statement} statements"
3086            ))),
3087        }
3088    }
3089
3090    /// Dispatch a graph-collection table-valued function call in FROM
3091    /// position (e.g. `SELECT * FROM components(g)`).
3092    ///
3093    /// Validates the function name and arity here, materializes the whole
3094    /// active graph read-only, then runs the algorithm via the shared
3095    /// `dispatch_graph_algorithm` path. Never mutates the catalog or store.
3096    fn execute_table_function(
3097        &self,
3098        name: &str,
3099        args: &[String],
3100        named_args: &[(String, f64)],
3101    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
3102        if name.eq_ignore_ascii_case("red.diff") {
3103            return self.execute_vcs_diff_tvf(args, named_args);
3104        }
3105        if !is_graph_tvf_name(name) {
3106            return Err(RedDBError::Query(format!("unknown table function: {name}")));
3107        }
3108        // Every graph-collection TVF takes exactly one graph argument.
3109        if args.len() != 1 {
3110            return Err(RedDBError::Query(format!(
3111                "table function '{name}' takes exactly 1 graph argument, got {}",
3112                args.len()
3113            )));
3114        }
3115
3116        // Read-only materialization of the full active graph. Passing `None`
3117        // for the projection uses the full graph store. Like #795/#796, the
3118        // v0 form runs over the whole graph store regardless of the collection
3119        // argument value. Materialization never mutates any store.
3120        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
3121        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
3122    }
3123
3124    pub(crate) fn revive_versioned_updates_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
3125        let mut guard = self.inner.pending_versioned_updates.write();
3126        let Some(pending) = guard.get_mut(&conn_id) else {
3127            return 0;
3128        };
3129
3130        let store = self.inner.db.store();
3131        let mut reverted = 0usize;
3132        pending.retain(|(collection, old_id, new_id, xid, previous_xmax)| {
3133            if *xid < stamper_xid {
3134                return true;
3135            }
3136            if let Some(manager) = store.get_collection(collection) {
3137                if let Some(mut old) = manager.get(*old_id) {
3138                    if old.xmax == *xid {
3139                        old.set_xmax(*previous_xmax);
3140                        let _ = manager.update(old);
3141                    }
3142                }
3143            }
3144            let _ = store.delete_batch(collection, &[*new_id]);
3145            reverted += 1;
3146            false
3147        });
3148        if pending.is_empty() {
3149            guard.remove(&conn_id);
3150        }
3151        reverted
3152    }
3153
3154    /// Wrap the planner's `RuntimeQueryExplain` as rows on a
3155    /// `RuntimeQueryResult` so callers over the SQL interface see the
3156    /// plan tree in the same shape a SELECT produces.
3157    ///
3158    /// Columns: `op`, `source`, `est_rows`, `est_cost`, `depth`.
3159    /// Nodes are walked depth-first; `depth` counts from 0 at the
3160    /// root so a text renderer can indent without re-walking.
3161    fn explain_as_rows(&self, raw_query: &str, inner_sql: &str) -> RedDBResult<RuntimeQueryResult> {
3162        let explain = self.explain_query(inner_sql)?;
3163
3164        let columns = vec![
3165            "op".to_string(),
3166            "source".to_string(),
3167            "est_rows".to_string(),
3168            "est_cost".to_string(),
3169            "depth".to_string(),
3170        ];
3171
3172        let mut records: Vec<crate::storage::query::unified::UnifiedRecord> = Vec::new();
3173
3174        // Prepend `CteScan` markers when the query carried a leading
3175        // WITH clause. The CTE bodies are already inlined into the
3176        // main plan tree, but operators reading EXPLAIN need to see
3177        // which named CTEs were resolved — without this row the plan
3178        // would look indistinguishable from a hand-inlined query.
3179        for name in &explain.cte_materializations {
3180            use std::sync::Arc;
3181            let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3182            rec.set_arc(Arc::from("op"), Value::text("CteScan".to_string()));
3183            rec.set_arc(Arc::from("source"), Value::text(name.clone()));
3184            rec.set_arc(Arc::from("est_rows"), Value::Float(0.0));
3185            rec.set_arc(Arc::from("est_cost"), Value::Float(0.0));
3186            rec.set_arc(Arc::from("depth"), Value::Integer(0));
3187            records.push(rec);
3188        }
3189
3190        walk_plan_node(&explain.logical_plan.root, 0, &mut records);
3191
3192        let result = crate::storage::query::unified::UnifiedResult {
3193            columns,
3194            records,
3195            stats: Default::default(),
3196            pre_serialized_json: None,
3197        };
3198
3199        Ok(RuntimeQueryResult {
3200            query: raw_query.to_string(),
3201            mode: explain.mode,
3202            statement: "explain",
3203            engine: "runtime-explain",
3204            result,
3205            affected_rows: 0,
3206            statement_type: "select",
3207            bookmark: None,
3208        })
3209    }
3210}
3211
3212fn walk_plan_node(
3213    node: &crate::storage::query::planner::CanonicalLogicalNode,
3214    depth: usize,
3215    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3216) {
3217    use std::sync::Arc;
3218    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3219    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3220    rec.set_arc(
3221        Arc::from("source"),
3222        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3223    );
3224    rec.set_arc(Arc::from("est_rows"), Value::Float(node.estimated_rows));
3225    rec.set_arc(Arc::from("est_cost"), Value::Float(node.operator_cost));
3226    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3227    out.push(rec);
3228    for child in &node.children {
3229        walk_plan_node(child, depth + 1, out);
3230    }
3231}
3232
3233#[cfg(test)]
3234mod inline_graph_tvf_tests {
3235    use super::*;
3236
3237    fn scopes_for(sql: &str) -> HashSet<String> {
3238        let expr = crate::storage::query::parser::parse(sql)
3239            .expect("parse")
3240            .query;
3241        query_expr_result_cache_scopes(&expr)
3242    }
3243
3244    #[test]
3245    fn inline_tvf_cache_scopes_include_source_collections() {
3246        // The result-cache key for the inline form must derive from the
3247        // `nodes`/`edges` source collections so a write to either invalidates
3248        // the cached result (issue #799).
3249        let scopes = scopes_for(
3250            "SELECT * FROM components(nodes => (SELECT id FROM hosts), edges => (SELECT src, dst FROM links))",
3251        );
3252        assert!(scopes.contains("hosts"), "nodes source scoped: {scopes:?}");
3253        assert!(scopes.contains("links"), "edges source scoped: {scopes:?}");
3254    }
3255
3256    #[test]
3257    fn graph_collection_tvf_cache_scope_is_graph_argument() {
3258        // The graph-collection form still materializes the active graph, but
3259        // result-cache invalidation is scoped to the named graph argument so
3260        // INSERT INTO g NODE/EDGE invalidates cached TVF rows.
3261        let scopes = scopes_for("SELECT * FROM components(g)");
3262        assert!(scopes.contains("g"), "collection form scoped: {scopes:?}");
3263    }
3264
3265    #[test]
3266    fn abstract_degree_centrality_counts_undirected_endpoints() {
3267        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
3268        let edges = vec![
3269            ("a".to_string(), "b".to_string(), 1.0_f32),
3270            ("b".to_string(), "c".to_string(), 1.0_f32),
3271        ];
3272        let degrees = abstract_degree_centrality(&nodes, &edges);
3273        assert_eq!(
3274            degrees,
3275            vec![
3276                ("a".to_string(), 1),
3277                ("b".to_string(), 2),
3278                ("c".to_string(), 1),
3279            ]
3280        );
3281    }
3282}