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