Skip to main content

icydb_core/db/session/sql/
mod.rs

1//! Module: db::session::sql
2//! Responsibility: session-owned SQL execution, explain, projection, and
3//! surface-classification helpers above lowered SQL commands.
4//! Does not own: SQL parsing or structural executor runtime behavior.
5//! Boundary: keeps session visibility, authority selection, and SQL surface routing in one subsystem.
6
7mod execute;
8mod explain;
9mod projection;
10
11#[cfg(feature = "perf-attribution")]
12use candid::CandidType;
13use icydb_utils::Xxh3;
14#[cfg(feature = "perf-attribution")]
15use serde::Deserialize;
16use std::{cell::RefCell, collections::HashMap, hash::BuildHasherDefault};
17
18type CacheBuildHasher = BuildHasherDefault<Xxh3>;
19
20// Bump these when SQL cache-key meaning changes in a way that must force
21// existing in-heap entries to miss instead of aliasing old semantics.
22const SQL_COMPILED_COMMAND_CACHE_METHOD_VERSION: u8 = 1;
23const SQL_SELECT_PLAN_CACHE_METHOD_VERSION: u8 = 1;
24
25#[cfg(feature = "perf-attribution")]
26use crate::db::DataStore;
27use crate::db::sql::parser::{SqlDeleteStatement, SqlInsertStatement, SqlUpdateStatement};
28use crate::{
29    db::{
30        DbSession, GroupedRow, PersistedRow, QueryError,
31        commit::CommitSchemaFingerprint,
32        executor::EntityAuthority,
33        query::{
34            intent::StructuralQuery,
35            plan::{AccessPlannedQuery, VisibleIndexes},
36        },
37        schema::commit_schema_fingerprint_for_entity,
38        session::query::QueryPlanCacheAttribution,
39        session::sql::projection::{
40            projection_fixed_scales_from_projection_spec, projection_labels_from_projection_spec,
41        },
42        sql::lowering::{LoweredBaseQueryShape, LoweredSqlCommand, SqlGlobalAggregateCommandCore},
43        sql::parser::{SqlStatement, parse_sql},
44    },
45    traits::{CanisterKind, EntityValue},
46};
47
48#[cfg(test)]
49use crate::db::{
50    MissingRowPolicy, PagedGroupedExecutionWithTrace,
51    sql::lowering::{
52        bind_lowered_sql_query, lower_sql_command_from_prepared_statement, prepare_sql_statement,
53    },
54};
55
56#[cfg(all(test, not(feature = "structural-read-metrics")))]
57pub(crate) use crate::db::session::sql::projection::with_sql_projection_materialization_metrics;
58#[cfg(feature = "structural-read-metrics")]
59pub use crate::db::session::sql::projection::{
60    SqlProjectionMaterializationMetrics, with_sql_projection_materialization_metrics,
61};
62
63/// Unified SQL statement payload returned by shared SQL lane execution.
64#[derive(Debug)]
65pub enum SqlStatementResult {
66    Count {
67        row_count: u32,
68    },
69    Projection {
70        columns: Vec<String>,
71        fixed_scales: Vec<Option<u32>>,
72        rows: Vec<Vec<crate::value::Value>>,
73        row_count: u32,
74    },
75    ProjectionText {
76        columns: Vec<String>,
77        rows: Vec<Vec<String>>,
78        row_count: u32,
79    },
80    Grouped {
81        columns: Vec<String>,
82        rows: Vec<GroupedRow>,
83        row_count: u32,
84        next_cursor: Option<String>,
85    },
86    Explain(String),
87    Describe(crate::db::EntitySchemaDescription),
88    ShowIndexes(Vec<String>),
89    ShowColumns(Vec<crate::db::EntityFieldDescription>),
90    ShowEntities(Vec<String>),
91}
92
93///
94/// SqlQueryExecutionAttribution
95///
96/// SqlQueryExecutionAttribution records the top-level reduced SQL query cost
97/// split at the new compile/execute seam.
98/// This keeps future cache validation focused on one concrete question:
99/// whether repeated queries stop paying compile cost while execute cost stays
100/// otherwise comparable.
101///
102
103#[cfg(feature = "perf-attribution")]
104#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
105pub struct SqlQueryExecutionAttribution {
106    pub compile_local_instructions: u64,
107    pub planner_local_instructions: u64,
108    pub store_local_instructions: u64,
109    pub executor_local_instructions: u64,
110    pub store_get_calls: u64,
111    pub response_decode_local_instructions: u64,
112    pub execute_local_instructions: u64,
113    pub total_local_instructions: u64,
114    pub sql_compiled_command_cache_hits: u64,
115    pub sql_compiled_command_cache_misses: u64,
116    pub sql_select_plan_cache_hits: u64,
117    pub sql_select_plan_cache_misses: u64,
118    pub shared_query_plan_cache_hits: u64,
119    pub shared_query_plan_cache_misses: u64,
120}
121
122// SqlExecutePhaseAttribution keeps the execute side split into select-plan
123// work, physical store/index access, and narrower runtime execution so shell
124// tooling can show all three.
125#[cfg(feature = "perf-attribution")]
126#[expect(clippy::struct_field_names)]
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub(in crate::db) struct SqlExecutePhaseAttribution {
129    pub planner_local_instructions: u64,
130    pub store_local_instructions: u64,
131    pub executor_local_instructions: u64,
132}
133
134#[cfg(feature = "perf-attribution")]
135impl SqlExecutePhaseAttribution {
136    #[must_use]
137    pub(in crate::db) const fn from_execute_total_and_store_total(
138        execute_local_instructions: u64,
139        store_local_instructions: u64,
140    ) -> Self {
141        Self {
142            planner_local_instructions: 0,
143            store_local_instructions,
144            executor_local_instructions: execute_local_instructions
145                .saturating_sub(store_local_instructions),
146        }
147    }
148}
149
150// SqlCacheAttribution keeps the SQL-specific upper cache layers separate from
151// the shared lower query-plan cache so perf audits can tell which boundary
152// actually produced reuse on one query path.
153#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
154pub(in crate::db) struct SqlCacheAttribution {
155    pub sql_compiled_command_cache_hits: u64,
156    pub sql_compiled_command_cache_misses: u64,
157    pub sql_select_plan_cache_hits: u64,
158    pub sql_select_plan_cache_misses: u64,
159    pub shared_query_plan_cache_hits: u64,
160    pub shared_query_plan_cache_misses: u64,
161}
162
163impl SqlCacheAttribution {
164    #[must_use]
165    const fn none() -> Self {
166        Self {
167            sql_compiled_command_cache_hits: 0,
168            sql_compiled_command_cache_misses: 0,
169            sql_select_plan_cache_hits: 0,
170            sql_select_plan_cache_misses: 0,
171            shared_query_plan_cache_hits: 0,
172            shared_query_plan_cache_misses: 0,
173        }
174    }
175
176    #[must_use]
177    const fn sql_compiled_command_cache_hit() -> Self {
178        Self {
179            sql_compiled_command_cache_hits: 1,
180            ..Self::none()
181        }
182    }
183
184    #[must_use]
185    const fn sql_compiled_command_cache_miss() -> Self {
186        Self {
187            sql_compiled_command_cache_misses: 1,
188            ..Self::none()
189        }
190    }
191
192    #[must_use]
193    const fn sql_select_plan_cache_hit() -> Self {
194        Self {
195            sql_select_plan_cache_hits: 1,
196            ..Self::none()
197        }
198    }
199
200    #[must_use]
201    const fn sql_select_plan_cache_miss() -> Self {
202        Self {
203            sql_select_plan_cache_misses: 1,
204            ..Self::none()
205        }
206    }
207
208    #[must_use]
209    const fn from_shared_query_plan_cache(attribution: QueryPlanCacheAttribution) -> Self {
210        Self {
211            shared_query_plan_cache_hits: attribution.hits,
212            shared_query_plan_cache_misses: attribution.misses,
213            ..Self::none()
214        }
215    }
216
217    #[must_use]
218    const fn merge(self, other: Self) -> Self {
219        Self {
220            sql_compiled_command_cache_hits: self
221                .sql_compiled_command_cache_hits
222                .saturating_add(other.sql_compiled_command_cache_hits),
223            sql_compiled_command_cache_misses: self
224                .sql_compiled_command_cache_misses
225                .saturating_add(other.sql_compiled_command_cache_misses),
226            sql_select_plan_cache_hits: self
227                .sql_select_plan_cache_hits
228                .saturating_add(other.sql_select_plan_cache_hits),
229            sql_select_plan_cache_misses: self
230                .sql_select_plan_cache_misses
231                .saturating_add(other.sql_select_plan_cache_misses),
232            shared_query_plan_cache_hits: self
233                .shared_query_plan_cache_hits
234                .saturating_add(other.shared_query_plan_cache_hits),
235            shared_query_plan_cache_misses: self
236                .shared_query_plan_cache_misses
237                .saturating_add(other.shared_query_plan_cache_misses),
238        }
239    }
240}
241
242#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
243enum SqlCompiledCommandSurface {
244    Query,
245    Update,
246}
247
248///
249/// SqlCompiledCommandCacheKey
250///
251/// SqlCompiledCommandCacheKey pins one compiled SQL artifact to the exact
252/// session-local semantic boundary that produced it.
253/// The key is intentionally conservative: surface kind, entity path, schema
254/// fingerprint, and raw SQL text must all match before execution can reuse a
255/// prior compile result.
256///
257
258#[derive(Clone, Debug, Eq, Hash, PartialEq)]
259pub(in crate::db) struct SqlCompiledCommandCacheKey {
260    cache_method_version: u8,
261    surface: SqlCompiledCommandSurface,
262    entity_path: &'static str,
263    schema_fingerprint: CommitSchemaFingerprint,
264    sql: String,
265}
266
267#[derive(Clone, Debug, Eq, Hash, PartialEq)]
268pub(in crate::db) struct SqlSelectPlanCacheKey {
269    cache_method_version: u8,
270    compiled: SqlCompiledCommandCacheKey,
271    visibility: crate::db::session::query::QueryPlanVisibility,
272}
273
274///
275/// SqlSelectPlanCacheEntry
276///
277/// SqlSelectPlanCacheEntry keeps the session-owned SQL projection contract
278/// beside the already planned structural SELECT access plan.
279/// This lets repeated SQL execution reuse both planner output and outward
280/// column-label derivation without caching executor-owned runtime state.
281///
282
283#[derive(Clone, Debug)]
284pub(in crate::db) struct SqlSelectPlanCacheEntry {
285    plan: AccessPlannedQuery,
286    columns: Vec<String>,
287    fixed_scales: Vec<Option<u32>>,
288}
289
290impl SqlSelectPlanCacheEntry {
291    #[must_use]
292    pub(in crate::db) const fn new(
293        plan: AccessPlannedQuery,
294        columns: Vec<String>,
295        fixed_scales: Vec<Option<u32>>,
296    ) -> Self {
297        Self {
298            plan,
299            columns,
300            fixed_scales,
301        }
302    }
303
304    #[must_use]
305    pub(in crate::db) fn into_parts(self) -> (AccessPlannedQuery, Vec<String>, Vec<Option<u32>>) {
306        (self.plan, self.columns, self.fixed_scales)
307    }
308}
309
310impl SqlCompiledCommandCacheKey {
311    fn query_for_entity<E>(sql: &str) -> Self
312    where
313        E: PersistedRow + EntityValue,
314    {
315        Self::for_entity::<E>(SqlCompiledCommandSurface::Query, sql)
316    }
317
318    fn update_for_entity<E>(sql: &str) -> Self
319    where
320        E: PersistedRow + EntityValue,
321    {
322        Self::for_entity::<E>(SqlCompiledCommandSurface::Update, sql)
323    }
324
325    fn for_entity<E>(surface: SqlCompiledCommandSurface, sql: &str) -> Self
326    where
327        E: PersistedRow + EntityValue,
328    {
329        Self {
330            cache_method_version: SQL_COMPILED_COMMAND_CACHE_METHOD_VERSION,
331            surface,
332            entity_path: E::PATH,
333            schema_fingerprint: commit_schema_fingerprint_for_entity::<E>(),
334            sql: sql.to_string(),
335        }
336    }
337
338    #[must_use]
339    pub(in crate::db) const fn schema_fingerprint(&self) -> CommitSchemaFingerprint {
340        self.schema_fingerprint
341    }
342}
343
344impl SqlSelectPlanCacheKey {
345    const fn from_compiled_key(
346        compiled: SqlCompiledCommandCacheKey,
347        visibility: crate::db::session::query::QueryPlanVisibility,
348    ) -> Self {
349        Self {
350            cache_method_version: SQL_SELECT_PLAN_CACHE_METHOD_VERSION,
351            compiled,
352            visibility,
353        }
354    }
355}
356
357#[cfg(test)]
358impl SqlCompiledCommandCacheKey {
359    pub(in crate::db) fn query_for_entity_with_method_version<E>(
360        sql: &str,
361        cache_method_version: u8,
362    ) -> Self
363    where
364        E: PersistedRow + EntityValue,
365    {
366        Self::for_entity_with_method_version::<E>(
367            SqlCompiledCommandSurface::Query,
368            sql,
369            cache_method_version,
370        )
371    }
372
373    pub(in crate::db) fn update_for_entity_with_method_version<E>(
374        sql: &str,
375        cache_method_version: u8,
376    ) -> Self
377    where
378        E: PersistedRow + EntityValue,
379    {
380        Self::for_entity_with_method_version::<E>(
381            SqlCompiledCommandSurface::Update,
382            sql,
383            cache_method_version,
384        )
385    }
386
387    fn for_entity_with_method_version<E>(
388        surface: SqlCompiledCommandSurface,
389        sql: &str,
390        cache_method_version: u8,
391    ) -> Self
392    where
393        E: PersistedRow + EntityValue,
394    {
395        Self {
396            cache_method_version,
397            surface,
398            entity_path: E::PATH,
399            schema_fingerprint: commit_schema_fingerprint_for_entity::<E>(),
400            sql: sql.to_string(),
401        }
402    }
403}
404
405#[cfg(test)]
406impl SqlSelectPlanCacheKey {
407    pub(in crate::db) const fn from_compiled_key_with_method_version(
408        compiled: SqlCompiledCommandCacheKey,
409        visibility: crate::db::session::query::QueryPlanVisibility,
410        cache_method_version: u8,
411    ) -> Self {
412        Self {
413            cache_method_version,
414            compiled,
415            visibility,
416        }
417    }
418}
419
420pub(in crate::db) type SqlCompiledCommandCache =
421    HashMap<SqlCompiledCommandCacheKey, CompiledSqlCommand, CacheBuildHasher>;
422pub(in crate::db) type SqlSelectPlanCache =
423    HashMap<SqlSelectPlanCacheKey, SqlSelectPlanCacheEntry, CacheBuildHasher>;
424
425thread_local! {
426    // Keep SQL-facing caches in canister-lifetime heap state keyed by the
427    // store registry identity so update calls can warm query-facing SQL reuse
428    // without leaking entries across unrelated registries in tests.
429    static SQL_COMPILED_COMMAND_CACHES: RefCell<HashMap<usize, SqlCompiledCommandCache, CacheBuildHasher>> =
430        RefCell::new(HashMap::default());
431    static SQL_SELECT_PLAN_CACHES: RefCell<HashMap<usize, SqlSelectPlanCache, CacheBuildHasher>> =
432        RefCell::new(HashMap::default());
433}
434
435// Keep the compile artifact session-owned and generic-free so the SQL surface
436// can separate semantic compilation from execution without coupling the seam to
437// typed entity binding or executor scratch state.
438#[derive(Clone, Debug)]
439pub(in crate::db) enum CompiledSqlCommand {
440    Select {
441        query: StructuralQuery,
442        compiled_cache_key: Option<SqlCompiledCommandCacheKey>,
443    },
444    Delete {
445        query: LoweredBaseQueryShape,
446        statement: SqlDeleteStatement,
447    },
448    GlobalAggregate {
449        command: SqlGlobalAggregateCommandCore,
450        label_overrides: Vec<Option<String>>,
451    },
452    Explain(LoweredSqlCommand),
453    Insert(SqlInsertStatement),
454    Update(SqlUpdateStatement),
455    DescribeEntity,
456    ShowIndexesEntity,
457    ShowColumnsEntity,
458    ShowEntities,
459}
460
461// Keep parsing as a module-owned helper instead of hanging a pure parser off
462// `DbSession` as a fake session method.
463pub(in crate::db) fn parse_sql_statement(sql: &str) -> Result<SqlStatement, QueryError> {
464    parse_sql(sql).map_err(QueryError::from_sql_parse_error)
465}
466
467#[cfg(feature = "perf-attribution")]
468#[expect(
469    clippy::missing_const_for_fn,
470    reason = "the wasm32 branch reads the runtime performance counter and cannot be const"
471)]
472fn read_sql_local_instruction_counter() -> u64 {
473    #[cfg(target_arch = "wasm32")]
474    {
475        canic_cdk::api::performance_counter(1)
476    }
477
478    #[cfg(not(target_arch = "wasm32"))]
479    {
480        0
481    }
482}
483
484#[cfg(feature = "perf-attribution")]
485fn measure_sql_stage<T, E>(run: impl FnOnce() -> Result<T, E>) -> (u64, Result<T, E>) {
486    let start = read_sql_local_instruction_counter();
487    let result = run();
488    let delta = read_sql_local_instruction_counter().saturating_sub(start);
489
490    (delta, result)
491}
492
493impl<C: CanisterKind> DbSession<C> {
494    fn sql_cache_scope_id(&self) -> usize {
495        self.db.cache_scope_id()
496    }
497
498    fn with_sql_compiled_command_cache<R>(
499        &self,
500        f: impl FnOnce(&mut SqlCompiledCommandCache) -> R,
501    ) -> R {
502        let scope_id = self.sql_cache_scope_id();
503
504        SQL_COMPILED_COMMAND_CACHES.with(|caches| {
505            let mut caches = caches.borrow_mut();
506            let cache = caches.entry(scope_id).or_default();
507
508            f(cache)
509        })
510    }
511
512    fn with_sql_select_plan_cache<R>(&self, f: impl FnOnce(&mut SqlSelectPlanCache) -> R) -> R {
513        let scope_id = self.sql_cache_scope_id();
514
515        SQL_SELECT_PLAN_CACHES.with(|caches| {
516            let mut caches = caches.borrow_mut();
517            let cache = caches.entry(scope_id).or_default();
518
519            f(cache)
520        })
521    }
522
523    #[cfg(test)]
524    pub(in crate::db) fn sql_compiled_command_cache_len(&self) -> usize {
525        self.with_sql_compiled_command_cache(|cache| cache.len())
526    }
527
528    #[cfg(test)]
529    pub(in crate::db) fn sql_select_plan_cache_len(&self) -> usize {
530        self.with_sql_select_plan_cache(|cache| cache.len())
531    }
532
533    #[cfg(test)]
534    pub(in crate::db) fn clear_sql_caches_for_tests(&self) {
535        self.with_sql_compiled_command_cache(SqlCompiledCommandCache::clear);
536        self.with_sql_select_plan_cache(SqlSelectPlanCache::clear);
537    }
538
539    fn planned_sql_select_with_visibility(
540        &self,
541        query: &StructuralQuery,
542        authority: EntityAuthority,
543        compiled_cache_key: Option<&SqlCompiledCommandCacheKey>,
544    ) -> Result<(SqlSelectPlanCacheEntry, SqlCacheAttribution), QueryError> {
545        let visibility = self.query_plan_visibility_for_store_path(authority.store_path())?;
546        let fallback_schema_fingerprint = crate::db::schema::commit_schema_fingerprint_for_model(
547            authority.model().path,
548            authority.model(),
549        );
550        let cache_schema_fingerprint = compiled_cache_key.map_or(
551            fallback_schema_fingerprint,
552            SqlCompiledCommandCacheKey::schema_fingerprint,
553        );
554
555        let Some(compiled_cache_key) = compiled_cache_key else {
556            let (entry, cache_attribution) = self.cached_query_plan_entry_for_authority(
557                authority,
558                cache_schema_fingerprint,
559                query,
560            )?;
561            let projection = entry.logical_plan().projection_spec(authority.model());
562            let columns = projection_labels_from_projection_spec(&projection);
563            let fixed_scales = projection_fixed_scales_from_projection_spec(&projection);
564
565            return Ok((
566                SqlSelectPlanCacheEntry::new(entry.logical_plan().clone(), columns, fixed_scales),
567                SqlCacheAttribution::from_shared_query_plan_cache(cache_attribution),
568            ));
569        };
570
571        let plan_cache_key =
572            SqlSelectPlanCacheKey::from_compiled_key(compiled_cache_key.clone(), visibility);
573        {
574            let cached =
575                self.with_sql_select_plan_cache(|cache| cache.get(&plan_cache_key).cloned());
576            if let Some(plan) = cached {
577                return Ok((plan, SqlCacheAttribution::sql_select_plan_cache_hit()));
578            }
579        }
580
581        let (entry, cache_attribution) =
582            self.cached_query_plan_entry_for_authority(authority, cache_schema_fingerprint, query)?;
583        let projection = entry.logical_plan().projection_spec(authority.model());
584        let columns = projection_labels_from_projection_spec(&projection);
585        let fixed_scales = projection_fixed_scales_from_projection_spec(&projection);
586        let entry =
587            SqlSelectPlanCacheEntry::new(entry.logical_plan().clone(), columns, fixed_scales);
588        self.with_sql_select_plan_cache(|cache| {
589            cache.insert(plan_cache_key, entry.clone());
590        });
591
592        Ok((
593            entry,
594            SqlCacheAttribution::sql_select_plan_cache_miss().merge(
595                SqlCacheAttribution::from_shared_query_plan_cache(cache_attribution),
596            ),
597        ))
598    }
599
600    // Resolve planner-visible indexes and build one execution-ready
601    // structural plan at the session SQL boundary.
602    pub(in crate::db::session::sql) fn build_structural_plan_with_visible_indexes_for_authority(
603        &self,
604        query: StructuralQuery,
605        authority: EntityAuthority,
606    ) -> Result<(VisibleIndexes<'_>, AccessPlannedQuery), QueryError> {
607        let visible_indexes =
608            self.visible_indexes_for_store_model(authority.store_path(), authority.model())?;
609        let plan = query.build_plan_with_visible_indexes(&visible_indexes)?;
610
611        Ok((visible_indexes, plan))
612    }
613
614    // Keep the public SQL query surface aligned with its name and with
615    // query-shaped canister entrypoints.
616    fn ensure_sql_query_statement_supported(statement: &SqlStatement) -> Result<(), QueryError> {
617        match statement {
618            SqlStatement::Select(_)
619            | SqlStatement::Explain(_)
620            | SqlStatement::Describe(_)
621            | SqlStatement::ShowIndexes(_)
622            | SqlStatement::ShowColumns(_)
623            | SqlStatement::ShowEntities(_) => Ok(()),
624            SqlStatement::Insert(_) => Err(QueryError::unsupported_query(
625                "execute_sql_query rejects INSERT; use execute_sql_update::<E>()",
626            )),
627            SqlStatement::Update(_) => Err(QueryError::unsupported_query(
628                "execute_sql_query rejects UPDATE; use execute_sql_update::<E>()",
629            )),
630            SqlStatement::Delete(_) => Err(QueryError::unsupported_query(
631                "execute_sql_query rejects DELETE; use execute_sql_update::<E>()",
632            )),
633        }
634    }
635
636    // Keep the public SQL mutation surface aligned with state-changing SQL
637    // while preserving one explicit read/introspection owner.
638    fn ensure_sql_update_statement_supported(statement: &SqlStatement) -> Result<(), QueryError> {
639        match statement {
640            SqlStatement::Insert(_) | SqlStatement::Update(_) | SqlStatement::Delete(_) => Ok(()),
641            SqlStatement::Select(_) => Err(QueryError::unsupported_query(
642                "execute_sql_update rejects SELECT; use execute_sql_query::<E>()",
643            )),
644            SqlStatement::Explain(_) => Err(QueryError::unsupported_query(
645                "execute_sql_update rejects EXPLAIN; use execute_sql_query::<E>()",
646            )),
647            SqlStatement::Describe(_) => Err(QueryError::unsupported_query(
648                "execute_sql_update rejects DESCRIBE; use execute_sql_query::<E>()",
649            )),
650            SqlStatement::ShowIndexes(_) => Err(QueryError::unsupported_query(
651                "execute_sql_update rejects SHOW INDEXES; use execute_sql_query::<E>()",
652            )),
653            SqlStatement::ShowColumns(_) => Err(QueryError::unsupported_query(
654                "execute_sql_update rejects SHOW COLUMNS; use execute_sql_query::<E>()",
655            )),
656            SqlStatement::ShowEntities(_) => Err(QueryError::unsupported_query(
657                "execute_sql_update rejects SHOW ENTITIES; use execute_sql_query::<E>()",
658            )),
659        }
660    }
661
662    /// Execute one single-entity reduced SQL query or introspection statement.
663    ///
664    /// This surface stays hard-bound to `E`, rejects state-changing SQL, and
665    /// returns SQL-shaped statement output instead of typed entities.
666    pub fn execute_sql_query<E>(&self, sql: &str) -> Result<SqlStatementResult, QueryError>
667    where
668        E: PersistedRow<Canister = C> + EntityValue,
669    {
670        let compiled = self.compile_sql_query::<E>(sql)?;
671
672        self.execute_compiled_sql::<E>(&compiled)
673    }
674
675    /// Execute one reduced SQL query while reporting the compile/execute split
676    /// at the top-level SQL seam.
677    #[cfg(feature = "perf-attribution")]
678    #[doc(hidden)]
679    pub fn execute_sql_query_with_attribution<E>(
680        &self,
681        sql: &str,
682    ) -> Result<(SqlStatementResult, SqlQueryExecutionAttribution), QueryError>
683    where
684        E: PersistedRow<Canister = C> + EntityValue,
685    {
686        // Phase 1: measure the compile side of the new seam, including parse,
687        // surface validation, and semantic command construction.
688        let (compile_local_instructions, compiled) =
689            measure_sql_stage(|| self.compile_sql_query_with_cache_attribution::<E>(sql));
690        let (compiled, compile_cache_attribution) = compiled?;
691
692        // Phase 2: measure the execute side separately so repeat-run cache
693        // experiments can prove which side actually moved.
694        let store_get_calls_before = DataStore::current_get_call_count();
695        let (result, execute_cache_attribution, execute_phase_attribution) =
696            self.execute_compiled_sql_with_phase_attribution::<E>(&compiled)?;
697        let store_get_calls =
698            DataStore::current_get_call_count().saturating_sub(store_get_calls_before);
699        let execute_local_instructions = execute_phase_attribution
700            .planner_local_instructions
701            .saturating_add(execute_phase_attribution.store_local_instructions)
702            .saturating_add(execute_phase_attribution.executor_local_instructions);
703        let cache_attribution = compile_cache_attribution.merge(execute_cache_attribution);
704        let total_local_instructions =
705            compile_local_instructions.saturating_add(execute_local_instructions);
706
707        Ok((
708            result,
709            SqlQueryExecutionAttribution {
710                compile_local_instructions,
711                planner_local_instructions: execute_phase_attribution.planner_local_instructions,
712                store_local_instructions: execute_phase_attribution.store_local_instructions,
713                executor_local_instructions: execute_phase_attribution.executor_local_instructions,
714                store_get_calls,
715                response_decode_local_instructions: 0,
716                execute_local_instructions,
717                total_local_instructions,
718                sql_compiled_command_cache_hits: cache_attribution.sql_compiled_command_cache_hits,
719                sql_compiled_command_cache_misses: cache_attribution
720                    .sql_compiled_command_cache_misses,
721                sql_select_plan_cache_hits: cache_attribution.sql_select_plan_cache_hits,
722                sql_select_plan_cache_misses: cache_attribution.sql_select_plan_cache_misses,
723                shared_query_plan_cache_hits: cache_attribution.shared_query_plan_cache_hits,
724                shared_query_plan_cache_misses: cache_attribution.shared_query_plan_cache_misses,
725            },
726        ))
727    }
728
729    /// Execute one single-entity reduced SQL mutation statement.
730    ///
731    /// This surface stays hard-bound to `E`, rejects read-only SQL, and
732    /// returns SQL-shaped mutation output such as counts or `RETURNING` rows.
733    pub fn execute_sql_update<E>(&self, sql: &str) -> Result<SqlStatementResult, QueryError>
734    where
735        E: PersistedRow<Canister = C> + EntityValue,
736    {
737        let compiled = self.compile_sql_update::<E>(sql)?;
738
739        self.execute_compiled_sql::<E>(&compiled)
740    }
741
742    #[cfg(test)]
743    pub(in crate::db) fn execute_grouped_sql_query_for_tests<E>(
744        &self,
745        sql: &str,
746        cursor_token: Option<&str>,
747    ) -> Result<PagedGroupedExecutionWithTrace, QueryError>
748    where
749        E: PersistedRow<Canister = C> + EntityValue,
750    {
751        let parsed = parse_sql_statement(sql)?;
752
753        let lowered = lower_sql_command_from_prepared_statement(
754            prepare_sql_statement(parsed, E::MODEL.name())
755                .map_err(QueryError::from_sql_lowering_error)?,
756            E::MODEL,
757        )
758        .map_err(QueryError::from_sql_lowering_error)?;
759        let Some(query) = lowered.query().cloned() else {
760            return Err(QueryError::unsupported_query(
761                "grouped SELECT helper requires grouped SELECT",
762            ));
763        };
764        let query = bind_lowered_sql_query::<E>(query, MissingRowPolicy::Ignore)
765            .map_err(QueryError::from_sql_lowering_error)?;
766        if !query.has_grouping() {
767            return Err(QueryError::unsupported_query(
768                "grouped SELECT helper requires grouped SELECT",
769            ));
770        }
771
772        self.execute_grouped(&query, cursor_token)
773    }
774
775    // Compile one SQL query-surface string into the session-owned generic-free
776    // semantic command artifact before execution.
777    pub(in crate::db) fn compile_sql_query<E>(
778        &self,
779        sql: &str,
780    ) -> Result<CompiledSqlCommand, QueryError>
781    where
782        E: PersistedRow<Canister = C> + EntityValue,
783    {
784        self.compile_sql_query_with_cache_attribution::<E>(sql)
785            .map(|(compiled, _)| compiled)
786    }
787
788    fn compile_sql_query_with_cache_attribution<E>(
789        &self,
790        sql: &str,
791    ) -> Result<(CompiledSqlCommand, SqlCacheAttribution), QueryError>
792    where
793        E: PersistedRow<Canister = C> + EntityValue,
794    {
795        self.compile_sql_statement_with_cache::<E>(
796            SqlCompiledCommandCacheKey::query_for_entity::<E>(sql),
797            sql,
798            Self::ensure_sql_query_statement_supported,
799        )
800    }
801
802    // Compile one SQL update-surface string into the session-owned generic-free
803    // semantic command artifact before execution.
804    pub(in crate::db) fn compile_sql_update<E>(
805        &self,
806        sql: &str,
807    ) -> Result<CompiledSqlCommand, QueryError>
808    where
809        E: PersistedRow<Canister = C> + EntityValue,
810    {
811        self.compile_sql_update_with_cache_attribution::<E>(sql)
812            .map(|(compiled, _)| compiled)
813    }
814
815    fn compile_sql_update_with_cache_attribution<E>(
816        &self,
817        sql: &str,
818    ) -> Result<(CompiledSqlCommand, SqlCacheAttribution), QueryError>
819    where
820        E: PersistedRow<Canister = C> + EntityValue,
821    {
822        self.compile_sql_statement_with_cache::<E>(
823            SqlCompiledCommandCacheKey::update_for_entity::<E>(sql),
824            sql,
825            Self::ensure_sql_update_statement_supported,
826        )
827    }
828
829    // Reuse one previously compiled SQL artifact when the session-local cache
830    // can prove the surface, entity contract, and raw SQL text all match.
831    fn compile_sql_statement_with_cache<E>(
832        &self,
833        cache_key: SqlCompiledCommandCacheKey,
834        sql: &str,
835        ensure_surface_supported: fn(&SqlStatement) -> Result<(), QueryError>,
836    ) -> Result<(CompiledSqlCommand, SqlCacheAttribution), QueryError>
837    where
838        E: PersistedRow<Canister = C> + EntityValue,
839    {
840        {
841            let cached =
842                self.with_sql_compiled_command_cache(|cache| cache.get(&cache_key).cloned());
843            if let Some(compiled) = cached {
844                return Ok((
845                    compiled,
846                    SqlCacheAttribution::sql_compiled_command_cache_hit(),
847                ));
848            }
849        }
850
851        let parsed = parse_sql_statement(sql)?;
852        ensure_surface_supported(&parsed)?;
853        let mut compiled = Self::compile_sql_statement_inner::<E>(&parsed)?;
854        if let CompiledSqlCommand::Select {
855            compiled_cache_key, ..
856        } = &mut compiled
857        {
858            *compiled_cache_key = Some(cache_key.clone());
859        }
860
861        self.with_sql_compiled_command_cache(|cache| {
862            cache.insert(cache_key, compiled.clone());
863        });
864
865        Ok((
866            compiled,
867            SqlCacheAttribution::sql_compiled_command_cache_miss(),
868        ))
869    }
870
871    // Compile one already-parsed SQL statement into the session-owned semantic
872    // command artifact used by the explicit compile -> execute seam.
873    pub(in crate::db) fn compile_sql_statement_inner<E>(
874        sql_statement: &SqlStatement,
875    ) -> Result<CompiledSqlCommand, QueryError>
876    where
877        E: PersistedRow<Canister = C> + EntityValue,
878    {
879        Self::compile_sql_statement_for_authority(sql_statement, EntityAuthority::for_type::<E>())
880    }
881}