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