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