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