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