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)]
469#[expect(
470    clippy::large_enum_variant,
471    reason = "compiled SQL keeps the full global aggregate command owned on the session boundary"
472)]
473pub(in crate::db) enum CompiledSqlCommand {
474    Select {
475        query: StructuralQuery,
476        compiled_cache_key: Option<SqlCompiledCommandCacheKey>,
477    },
478    Delete {
479        query: LoweredBaseQueryShape,
480        statement: SqlDeleteStatement,
481    },
482    GlobalAggregate {
483        command: SqlGlobalAggregateCommandCore,
484    },
485    Explain(LoweredSqlCommand),
486    Insert(SqlInsertStatement),
487    Update(SqlUpdateStatement),
488    DescribeEntity,
489    ShowIndexesEntity,
490    ShowColumnsEntity,
491    ShowEntities,
492}
493
494// Keep parsing as a module-owned helper instead of hanging a pure parser off
495// `DbSession` as a fake session method.
496pub(in crate::db) fn parse_sql_statement(sql: &str) -> Result<SqlStatement, QueryError> {
497    parse_sql(sql).map_err(QueryError::from_sql_parse_error)
498}
499
500#[cfg(feature = "diagnostics")]
501#[expect(
502    clippy::missing_const_for_fn,
503    reason = "the wasm32 branch reads the runtime performance counter and cannot be const"
504)]
505fn read_sql_local_instruction_counter() -> u64 {
506    #[cfg(target_arch = "wasm32")]
507    {
508        canic_cdk::api::performance_counter(1)
509    }
510
511    #[cfg(not(target_arch = "wasm32"))]
512    {
513        0
514    }
515}
516
517#[cfg(feature = "diagnostics")]
518fn measure_sql_stage<T, E>(run: impl FnOnce() -> Result<T, E>) -> (u64, Result<T, E>) {
519    let start = read_sql_local_instruction_counter();
520    let result = run();
521    let delta = read_sql_local_instruction_counter().saturating_sub(start);
522
523    (delta, result)
524}
525
526impl<C: CanisterKind> DbSession<C> {
527    fn sql_cache_scope_id(&self) -> usize {
528        self.db.cache_scope_id()
529    }
530
531    fn with_sql_compiled_command_cache<R>(
532        &self,
533        f: impl FnOnce(&mut SqlCompiledCommandCache) -> R,
534    ) -> R {
535        let scope_id = self.sql_cache_scope_id();
536
537        SQL_COMPILED_COMMAND_CACHES.with(|caches| {
538            let mut caches = caches.borrow_mut();
539            let cache = caches.entry(scope_id).or_default();
540
541            f(cache)
542        })
543    }
544
545    fn with_sql_select_plan_cache<R>(&self, f: impl FnOnce(&mut SqlSelectPlanCache) -> R) -> R {
546        let scope_id = self.sql_cache_scope_id();
547
548        SQL_SELECT_PLAN_CACHES.with(|caches| {
549            let mut caches = caches.borrow_mut();
550            let cache = caches.entry(scope_id).or_default();
551
552            f(cache)
553        })
554    }
555
556    #[cfg(test)]
557    pub(in crate::db) fn sql_compiled_command_cache_len(&self) -> usize {
558        self.with_sql_compiled_command_cache(|cache| cache.len())
559    }
560
561    #[cfg(test)]
562    pub(in crate::db) fn sql_select_plan_cache_len(&self) -> usize {
563        self.with_sql_select_plan_cache(|cache| cache.len())
564    }
565
566    #[cfg(test)]
567    pub(in crate::db) fn clear_sql_caches_for_tests(&self) {
568        self.with_sql_compiled_command_cache(SqlCompiledCommandCache::clear);
569        self.with_sql_select_plan_cache(SqlSelectPlanCache::clear);
570    }
571
572    fn planned_sql_select_with_visibility(
573        &self,
574        query: &StructuralQuery,
575        authority: EntityAuthority,
576        compiled_cache_key: Option<&SqlCompiledCommandCacheKey>,
577    ) -> Result<(SqlSelectPlanCacheEntry, SqlCacheAttribution), QueryError> {
578        let visibility = self.query_plan_visibility_for_store_path(authority.store_path())?;
579        let fallback_schema_fingerprint = crate::db::schema::commit_schema_fingerprint_for_model(
580            authority.model().path,
581            authority.model(),
582        );
583        let cache_schema_fingerprint = compiled_cache_key.map_or(
584            fallback_schema_fingerprint,
585            SqlCompiledCommandCacheKey::schema_fingerprint,
586        );
587
588        let Some(compiled_cache_key) = compiled_cache_key else {
589            let (entry, cache_attribution) = self.cached_query_plan_entry_for_authority(
590                authority,
591                cache_schema_fingerprint,
592                query,
593            )?;
594            let projection = entry.logical_plan().projection_spec(authority.model());
595            let columns = projection_labels_from_projection_spec(&projection);
596            let fixed_scales = projection_fixed_scales_from_projection_spec(&projection);
597
598            return Ok((
599                SqlSelectPlanCacheEntry::new(entry.prepared_plan().clone(), columns, fixed_scales),
600                SqlCacheAttribution::from_shared_query_plan_cache(cache_attribution),
601            ));
602        };
603
604        let plan_cache_key =
605            SqlSelectPlanCacheKey::from_compiled_key(compiled_cache_key.clone(), visibility);
606        {
607            let cached =
608                self.with_sql_select_plan_cache(|cache| cache.get(&plan_cache_key).cloned());
609            if let Some(plan) = cached {
610                return Ok((plan, SqlCacheAttribution::sql_select_plan_cache_hit()));
611            }
612        }
613
614        let (entry, cache_attribution) =
615            self.cached_query_plan_entry_for_authority(authority, cache_schema_fingerprint, query)?;
616        let projection = entry.logical_plan().projection_spec(authority.model());
617        let columns = projection_labels_from_projection_spec(&projection);
618        let fixed_scales = projection_fixed_scales_from_projection_spec(&projection);
619        let entry =
620            SqlSelectPlanCacheEntry::new(entry.prepared_plan().clone(), columns, fixed_scales);
621        self.with_sql_select_plan_cache(|cache| {
622            cache.insert(plan_cache_key, entry.clone());
623        });
624
625        Ok((
626            entry,
627            SqlCacheAttribution::sql_select_plan_cache_miss().merge(
628                SqlCacheAttribution::from_shared_query_plan_cache(cache_attribution),
629            ),
630        ))
631    }
632
633    // Resolve planner-visible indexes and build one execution-ready
634    // structural plan at the session SQL boundary.
635    pub(in crate::db::session::sql) fn build_structural_plan_with_visible_indexes_for_authority(
636        &self,
637        query: StructuralQuery,
638        authority: EntityAuthority,
639    ) -> Result<(VisibleIndexes<'_>, AccessPlannedQuery), QueryError> {
640        let visible_indexes =
641            self.visible_indexes_for_store_model(authority.store_path(), authority.model())?;
642        let plan = query.build_plan_with_visible_indexes(&visible_indexes)?;
643
644        Ok((visible_indexes, plan))
645    }
646
647    // Keep the public SQL query surface aligned with its name and with
648    // query-shaped canister entrypoints.
649    fn ensure_sql_query_statement_supported(statement: &SqlStatement) -> Result<(), QueryError> {
650        match statement {
651            SqlStatement::Select(_)
652            | SqlStatement::Explain(_)
653            | SqlStatement::Describe(_)
654            | SqlStatement::ShowIndexes(_)
655            | SqlStatement::ShowColumns(_)
656            | SqlStatement::ShowEntities(_) => Ok(()),
657            SqlStatement::Insert(_) => Err(QueryError::unsupported_query(
658                "execute_sql_query rejects INSERT; use execute_sql_update::<E>()",
659            )),
660            SqlStatement::Update(_) => Err(QueryError::unsupported_query(
661                "execute_sql_query rejects UPDATE; use execute_sql_update::<E>()",
662            )),
663            SqlStatement::Delete(_) => Err(QueryError::unsupported_query(
664                "execute_sql_query rejects DELETE; use execute_sql_update::<E>()",
665            )),
666        }
667    }
668
669    // Keep the public SQL mutation surface aligned with state-changing SQL
670    // while preserving one explicit read/introspection owner.
671    fn ensure_sql_update_statement_supported(statement: &SqlStatement) -> Result<(), QueryError> {
672        match statement {
673            SqlStatement::Insert(_) | SqlStatement::Update(_) | SqlStatement::Delete(_) => Ok(()),
674            SqlStatement::Select(_) => Err(QueryError::unsupported_query(
675                "execute_sql_update rejects SELECT; use execute_sql_query::<E>()",
676            )),
677            SqlStatement::Explain(_) => Err(QueryError::unsupported_query(
678                "execute_sql_update rejects EXPLAIN; use execute_sql_query::<E>()",
679            )),
680            SqlStatement::Describe(_) => Err(QueryError::unsupported_query(
681                "execute_sql_update rejects DESCRIBE; use execute_sql_query::<E>()",
682            )),
683            SqlStatement::ShowIndexes(_) => Err(QueryError::unsupported_query(
684                "execute_sql_update rejects SHOW INDEXES; use execute_sql_query::<E>()",
685            )),
686            SqlStatement::ShowColumns(_) => Err(QueryError::unsupported_query(
687                "execute_sql_update rejects SHOW COLUMNS; use execute_sql_query::<E>()",
688            )),
689            SqlStatement::ShowEntities(_) => Err(QueryError::unsupported_query(
690                "execute_sql_update rejects SHOW ENTITIES; use execute_sql_query::<E>()",
691            )),
692        }
693    }
694
695    /// Execute one single-entity reduced SQL query or introspection statement.
696    ///
697    /// This surface stays hard-bound to `E`, rejects state-changing SQL, and
698    /// returns SQL-shaped statement output instead of typed entities.
699    pub fn execute_sql_query<E>(&self, sql: &str) -> Result<SqlStatementResult, QueryError>
700    where
701        E: PersistedRow<Canister = C> + EntityValue,
702    {
703        let compiled = self.compile_sql_query::<E>(sql)?;
704
705        self.execute_compiled_sql::<E>(&compiled)
706    }
707
708    /// Execute one reduced SQL query while reporting the compile/execute split
709    /// at the top-level SQL seam.
710    #[cfg(feature = "diagnostics")]
711    #[doc(hidden)]
712    pub fn execute_sql_query_with_attribution<E>(
713        &self,
714        sql: &str,
715    ) -> Result<(SqlStatementResult, SqlQueryExecutionAttribution), QueryError>
716    where
717        E: PersistedRow<Canister = C> + EntityValue,
718    {
719        // Phase 1: measure the compile side of the new seam, including parse,
720        // surface validation, and semantic command construction.
721        let (compile_local_instructions, compiled) =
722            measure_sql_stage(|| self.compile_sql_query_with_cache_attribution::<E>(sql));
723        let (compiled, compile_cache_attribution) = compiled?;
724
725        // Phase 2: measure the execute side separately so repeat-run cache
726        // experiments can prove which side actually moved.
727        let store_get_calls_before = DataStore::current_get_call_count();
728        let pure_covering_decode_before = current_pure_covering_decode_local_instructions();
729        let pure_covering_row_assembly_before =
730            current_pure_covering_row_assembly_local_instructions();
731        let (result, execute_cache_attribution, execute_phase_attribution) =
732            self.execute_compiled_sql_with_phase_attribution::<E>(&compiled)?;
733        let store_get_calls =
734            DataStore::current_get_call_count().saturating_sub(store_get_calls_before);
735        let pure_covering_decode_local_instructions =
736            current_pure_covering_decode_local_instructions()
737                .saturating_sub(pure_covering_decode_before);
738        let pure_covering_row_assembly_local_instructions =
739            current_pure_covering_row_assembly_local_instructions()
740                .saturating_sub(pure_covering_row_assembly_before);
741        let execute_local_instructions = execute_phase_attribution
742            .planner_local_instructions
743            .saturating_add(execute_phase_attribution.store_local_instructions)
744            .saturating_add(execute_phase_attribution.executor_local_instructions);
745        let cache_attribution = compile_cache_attribution.merge(execute_cache_attribution);
746        let total_local_instructions =
747            compile_local_instructions.saturating_add(execute_local_instructions);
748
749        Ok((
750            result,
751            SqlQueryExecutionAttribution {
752                compile_local_instructions,
753                planner_local_instructions: execute_phase_attribution.planner_local_instructions,
754                store_local_instructions: execute_phase_attribution.store_local_instructions,
755                executor_local_instructions: execute_phase_attribution.executor_local_instructions,
756                grouped_stream_local_instructions: execute_phase_attribution
757                    .grouped_stream_local_instructions,
758                grouped_fold_local_instructions: execute_phase_attribution
759                    .grouped_fold_local_instructions,
760                grouped_finalize_local_instructions: execute_phase_attribution
761                    .grouped_finalize_local_instructions,
762                grouped_count_borrowed_hash_computations: execute_phase_attribution
763                    .grouped_count
764                    .borrowed_hash_computations,
765                grouped_count_bucket_candidate_checks: execute_phase_attribution
766                    .grouped_count
767                    .bucket_candidate_checks,
768                grouped_count_existing_group_hits: execute_phase_attribution
769                    .grouped_count
770                    .existing_group_hits,
771                grouped_count_new_group_inserts: execute_phase_attribution
772                    .grouped_count
773                    .new_group_inserts,
774                grouped_count_row_materialization_local_instructions: execute_phase_attribution
775                    .grouped_count
776                    .row_materialization_local_instructions,
777                grouped_count_group_lookup_local_instructions: execute_phase_attribution
778                    .grouped_count
779                    .group_lookup_local_instructions,
780                grouped_count_existing_group_update_local_instructions: execute_phase_attribution
781                    .grouped_count
782                    .existing_group_update_local_instructions,
783                grouped_count_new_group_insert_local_instructions: execute_phase_attribution
784                    .grouped_count
785                    .new_group_insert_local_instructions,
786                pure_covering_decode_local_instructions,
787                pure_covering_row_assembly_local_instructions,
788                store_get_calls,
789                response_decode_local_instructions: 0,
790                execute_local_instructions,
791                total_local_instructions,
792                sql_compiled_command_cache_hits: cache_attribution.sql_compiled_command_cache_hits,
793                sql_compiled_command_cache_misses: cache_attribution
794                    .sql_compiled_command_cache_misses,
795                sql_select_plan_cache_hits: cache_attribution.sql_select_plan_cache_hits,
796                sql_select_plan_cache_misses: cache_attribution.sql_select_plan_cache_misses,
797                shared_query_plan_cache_hits: cache_attribution.shared_query_plan_cache_hits,
798                shared_query_plan_cache_misses: cache_attribution.shared_query_plan_cache_misses,
799            },
800        ))
801    }
802
803    /// Execute one single-entity reduced SQL mutation statement.
804    ///
805    /// This surface stays hard-bound to `E`, rejects read-only SQL, and
806    /// returns SQL-shaped mutation output such as counts or `RETURNING` rows.
807    pub fn execute_sql_update<E>(&self, sql: &str) -> Result<SqlStatementResult, QueryError>
808    where
809        E: PersistedRow<Canister = C> + EntityValue,
810    {
811        let compiled = self.compile_sql_update::<E>(sql)?;
812
813        self.execute_compiled_sql::<E>(&compiled)
814    }
815
816    #[cfg(test)]
817    pub(in crate::db) fn execute_grouped_sql_query_for_tests<E>(
818        &self,
819        sql: &str,
820        cursor_token: Option<&str>,
821    ) -> Result<PagedGroupedExecutionWithTrace, QueryError>
822    where
823        E: PersistedRow<Canister = C> + EntityValue,
824    {
825        let parsed = parse_sql_statement(sql)?;
826
827        let lowered = lower_sql_command_from_prepared_statement(
828            prepare_sql_statement(parsed, E::MODEL.name())
829                .map_err(QueryError::from_sql_lowering_error)?,
830            E::MODEL,
831        )
832        .map_err(QueryError::from_sql_lowering_error)?;
833        let Some(query) = lowered.query().cloned() else {
834            return Err(QueryError::unsupported_query(
835                "grouped SELECT helper requires grouped SELECT",
836            ));
837        };
838        let query = bind_lowered_sql_query::<E>(query, MissingRowPolicy::Ignore)
839            .map_err(QueryError::from_sql_lowering_error)?;
840        if !query.has_grouping() {
841            return Err(QueryError::unsupported_query(
842                "grouped SELECT helper requires grouped SELECT",
843            ));
844        }
845
846        self.execute_grouped(&query, cursor_token)
847    }
848
849    // Compile one SQL query-surface string into the session-owned generic-free
850    // semantic command artifact before execution.
851    pub(in crate::db) fn compile_sql_query<E>(
852        &self,
853        sql: &str,
854    ) -> Result<CompiledSqlCommand, QueryError>
855    where
856        E: PersistedRow<Canister = C> + EntityValue,
857    {
858        self.compile_sql_query_with_cache_attribution::<E>(sql)
859            .map(|(compiled, _)| compiled)
860    }
861
862    fn compile_sql_query_with_cache_attribution<E>(
863        &self,
864        sql: &str,
865    ) -> Result<(CompiledSqlCommand, SqlCacheAttribution), QueryError>
866    where
867        E: PersistedRow<Canister = C> + EntityValue,
868    {
869        self.compile_sql_statement_with_cache::<E>(
870            SqlCompiledCommandCacheKey::query_for_entity::<E>(sql),
871            sql,
872            Self::ensure_sql_query_statement_supported,
873        )
874    }
875
876    // Compile one SQL update-surface string into the session-owned generic-free
877    // semantic command artifact before execution.
878    pub(in crate::db) fn compile_sql_update<E>(
879        &self,
880        sql: &str,
881    ) -> Result<CompiledSqlCommand, QueryError>
882    where
883        E: PersistedRow<Canister = C> + EntityValue,
884    {
885        self.compile_sql_update_with_cache_attribution::<E>(sql)
886            .map(|(compiled, _)| compiled)
887    }
888
889    fn compile_sql_update_with_cache_attribution<E>(
890        &self,
891        sql: &str,
892    ) -> Result<(CompiledSqlCommand, SqlCacheAttribution), QueryError>
893    where
894        E: PersistedRow<Canister = C> + EntityValue,
895    {
896        self.compile_sql_statement_with_cache::<E>(
897            SqlCompiledCommandCacheKey::update_for_entity::<E>(sql),
898            sql,
899            Self::ensure_sql_update_statement_supported,
900        )
901    }
902
903    // Reuse one previously compiled SQL artifact when the session-local cache
904    // can prove the surface, entity contract, and raw SQL text all match.
905    fn compile_sql_statement_with_cache<E>(
906        &self,
907        cache_key: SqlCompiledCommandCacheKey,
908        sql: &str,
909        ensure_surface_supported: fn(&SqlStatement) -> Result<(), QueryError>,
910    ) -> Result<(CompiledSqlCommand, SqlCacheAttribution), QueryError>
911    where
912        E: PersistedRow<Canister = C> + EntityValue,
913    {
914        {
915            let cached =
916                self.with_sql_compiled_command_cache(|cache| cache.get(&cache_key).cloned());
917            if let Some(compiled) = cached {
918                return Ok((
919                    compiled,
920                    SqlCacheAttribution::sql_compiled_command_cache_hit(),
921                ));
922            }
923        }
924
925        let parsed = parse_sql_statement(sql)?;
926        ensure_surface_supported(&parsed)?;
927        let mut compiled = Self::compile_sql_statement_inner::<E>(&parsed)?;
928        if let CompiledSqlCommand::Select {
929            compiled_cache_key, ..
930        } = &mut compiled
931        {
932            *compiled_cache_key = Some(cache_key.clone());
933        }
934
935        self.with_sql_compiled_command_cache(|cache| {
936            cache.insert(cache_key, compiled.clone());
937        });
938
939        Ok((
940            compiled,
941            SqlCacheAttribution::sql_compiled_command_cache_miss(),
942        ))
943    }
944
945    // Compile one already-parsed SQL statement into the session-owned semantic
946    // command artifact used by the explicit compile -> execute seam.
947    pub(in crate::db) fn compile_sql_statement_inner<E>(
948        sql_statement: &SqlStatement,
949    ) -> Result<CompiledSqlCommand, QueryError>
950    where
951        E: PersistedRow<Canister = C> + EntityValue,
952    {
953        Self::compile_sql_statement_for_authority(sql_statement, EntityAuthority::for_type::<E>())
954    }
955}