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, QueryPlanCacheEntry},
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-owned prepared-select cache entry from one shared lower
561    // query-plan cache entry so both cached and explicit-bypass paths reuse the
562    // same outward projection-contract construction.
563    fn sql_select_plan_entry_from_shared_query_plan_entry(
564        authority: EntityAuthority,
565        entry: &QueryPlanCacheEntry,
566    ) -> SqlSelectPlanCacheEntry {
567        let projection = entry.logical_plan().projection_spec(authority.model());
568        let columns = projection_labels_from_projection_spec(&projection);
569        let fixed_scales = projection_fixed_scales_from_projection_spec(&projection);
570
571        SqlSelectPlanCacheEntry::new(entry.prepared_plan().clone(), columns, fixed_scales)
572    }
573
574    // Resolve one SQL-owned prepared-select entry through the shared lower
575    // query-plan cache so cached and explicit-bypass paths share the same
576    // lower-cache fetch and SQL projection-contract construction.
577    fn sql_select_plan_entry_from_shared_query_plan(
578        &self,
579        query: &StructuralQuery,
580        authority: EntityAuthority,
581        cache_schema_fingerprint: CommitSchemaFingerprint,
582    ) -> Result<(SqlSelectPlanCacheEntry, QueryPlanCacheAttribution), QueryError> {
583        let (entry, cache_attribution) =
584            self.cached_query_plan_entry_for_authority(authority, cache_schema_fingerprint, query)?;
585
586        Ok((
587            Self::sql_select_plan_entry_from_shared_query_plan_entry(authority, &entry),
588            cache_attribution,
589        ))
590    }
591
592    // Build one SQL projection/plan entry directly from the shared lower
593    // query-plan cache for explicit uncached or lowered-only SELECT paths.
594    fn planned_sql_select_without_sql_cache(
595        &self,
596        query: &StructuralQuery,
597        authority: EntityAuthority,
598    ) -> Result<(SqlSelectPlanCacheEntry, SqlCacheAttribution), QueryError> {
599        let cache_schema_fingerprint = crate::db::schema::commit_schema_fingerprint_for_model(
600            authority.model().path,
601            authority.model(),
602        );
603        let (entry, cache_attribution) = self.sql_select_plan_entry_from_shared_query_plan(
604            query,
605            authority,
606            cache_schema_fingerprint,
607        )?;
608
609        Ok((
610            entry,
611            SqlCacheAttribution::from_shared_query_plan_cache(cache_attribution),
612        ))
613    }
614
615    // Resolve one normal SQL SELECT through the session-owned visibility-aware
616    // prepared-select cache instead of falling back into the shared cache story.
617    fn planned_sql_select_with_visibility(
618        &self,
619        query: &StructuralQuery,
620        authority: EntityAuthority,
621        compiled_cache_key: &SqlCompiledCommandCacheKey,
622    ) -> Result<(SqlSelectPlanCacheEntry, SqlCacheAttribution), QueryError> {
623        let visibility = self.query_plan_visibility_for_store_path(authority.store_path())?;
624        let cache_schema_fingerprint = compiled_cache_key.schema_fingerprint();
625
626        let plan_cache_key =
627            SqlSelectPlanCacheKey::from_compiled_key(compiled_cache_key.clone(), visibility);
628        {
629            let cached =
630                self.with_sql_select_plan_cache(|cache| cache.get(&plan_cache_key).cloned());
631            if let Some(plan) = cached {
632                return Ok((plan, SqlCacheAttribution::sql_select_plan_cache_hit()));
633            }
634        }
635
636        let (entry, cache_attribution) = self.sql_select_plan_entry_from_shared_query_plan(
637            query,
638            authority,
639            cache_schema_fingerprint,
640        )?;
641        self.with_sql_select_plan_cache(|cache| {
642            cache.insert(plan_cache_key, entry.clone());
643        });
644
645        Ok((
646            entry,
647            SqlCacheAttribution::sql_select_plan_cache_miss().merge(
648                SqlCacheAttribution::from_shared_query_plan_cache(cache_attribution),
649            ),
650        ))
651    }
652
653    // Resolve planner-visible indexes and build one execution-ready
654    // structural plan at the session SQL boundary.
655    pub(in crate::db::session::sql) fn build_structural_plan_with_visible_indexes_for_authority(
656        &self,
657        query: StructuralQuery,
658        authority: EntityAuthority,
659    ) -> Result<(VisibleIndexes<'_>, AccessPlannedQuery), QueryError> {
660        let visible_indexes =
661            self.visible_indexes_for_store_model(authority.store_path(), authority.model())?;
662        let plan = query.build_plan_with_visible_indexes(&visible_indexes)?;
663
664        Ok((visible_indexes, plan))
665    }
666
667    // Keep the public SQL query surface aligned with its name and with
668    // query-shaped canister entrypoints.
669    fn ensure_sql_query_statement_supported(statement: &SqlStatement) -> Result<(), QueryError> {
670        match statement {
671            SqlStatement::Select(_)
672            | SqlStatement::Explain(_)
673            | SqlStatement::Describe(_)
674            | SqlStatement::ShowIndexes(_)
675            | SqlStatement::ShowColumns(_)
676            | SqlStatement::ShowEntities(_) => Ok(()),
677            SqlStatement::Insert(_) => Err(QueryError::unsupported_query(
678                "execute_sql_query rejects INSERT; use execute_sql_update::<E>()",
679            )),
680            SqlStatement::Update(_) => Err(QueryError::unsupported_query(
681                "execute_sql_query rejects UPDATE; use execute_sql_update::<E>()",
682            )),
683            SqlStatement::Delete(_) => Err(QueryError::unsupported_query(
684                "execute_sql_query rejects DELETE; use execute_sql_update::<E>()",
685            )),
686        }
687    }
688
689    // Keep the public SQL mutation surface aligned with state-changing SQL
690    // while preserving one explicit read/introspection owner.
691    fn ensure_sql_update_statement_supported(statement: &SqlStatement) -> Result<(), QueryError> {
692        match statement {
693            SqlStatement::Insert(_) | SqlStatement::Update(_) | SqlStatement::Delete(_) => Ok(()),
694            SqlStatement::Select(_) => Err(QueryError::unsupported_query(
695                "execute_sql_update rejects SELECT; use execute_sql_query::<E>()",
696            )),
697            SqlStatement::Explain(_) => Err(QueryError::unsupported_query(
698                "execute_sql_update rejects EXPLAIN; use execute_sql_query::<E>()",
699            )),
700            SqlStatement::Describe(_) => Err(QueryError::unsupported_query(
701                "execute_sql_update rejects DESCRIBE; use execute_sql_query::<E>()",
702            )),
703            SqlStatement::ShowIndexes(_) => Err(QueryError::unsupported_query(
704                "execute_sql_update rejects SHOW INDEXES; use execute_sql_query::<E>()",
705            )),
706            SqlStatement::ShowColumns(_) => Err(QueryError::unsupported_query(
707                "execute_sql_update rejects SHOW COLUMNS; use execute_sql_query::<E>()",
708            )),
709            SqlStatement::ShowEntities(_) => Err(QueryError::unsupported_query(
710                "execute_sql_update rejects SHOW ENTITIES; use execute_sql_query::<E>()",
711            )),
712        }
713    }
714
715    /// Execute one single-entity reduced SQL query or introspection statement.
716    ///
717    /// This surface stays hard-bound to `E`, rejects state-changing SQL, and
718    /// returns SQL-shaped statement output instead of typed entities.
719    pub fn execute_sql_query<E>(&self, sql: &str) -> Result<SqlStatementResult, QueryError>
720    where
721        E: PersistedRow<Canister = C> + EntityValue,
722    {
723        let compiled = self.compile_sql_query::<E>(sql)?;
724
725        self.execute_compiled_sql::<E>(&compiled)
726    }
727
728    /// Execute one reduced SQL query while reporting the compile/execute split
729    /// at the top-level SQL seam.
730    #[cfg(feature = "diagnostics")]
731    #[doc(hidden)]
732    pub fn execute_sql_query_with_attribution<E>(
733        &self,
734        sql: &str,
735    ) -> Result<(SqlStatementResult, SqlQueryExecutionAttribution), QueryError>
736    where
737        E: PersistedRow<Canister = C> + EntityValue,
738    {
739        // Phase 1: measure the compile side of the new seam, including parse,
740        // surface validation, and semantic command construction.
741        let (compile_local_instructions, compiled) =
742            measure_sql_stage(|| self.compile_sql_query_with_cache_attribution::<E>(sql));
743        let (compiled, compile_cache_attribution) = compiled?;
744
745        // Phase 2: measure the execute side separately so repeat-run cache
746        // experiments can prove which side actually moved.
747        let store_get_calls_before = DataStore::current_get_call_count();
748        let pure_covering_decode_before = current_pure_covering_decode_local_instructions();
749        let pure_covering_row_assembly_before =
750            current_pure_covering_row_assembly_local_instructions();
751        let (result, execute_cache_attribution, execute_phase_attribution) =
752            self.execute_compiled_sql_with_phase_attribution::<E>(&compiled)?;
753        let store_get_calls =
754            DataStore::current_get_call_count().saturating_sub(store_get_calls_before);
755        let pure_covering_decode_local_instructions =
756            current_pure_covering_decode_local_instructions()
757                .saturating_sub(pure_covering_decode_before);
758        let pure_covering_row_assembly_local_instructions =
759            current_pure_covering_row_assembly_local_instructions()
760                .saturating_sub(pure_covering_row_assembly_before);
761        let execute_local_instructions = execute_phase_attribution
762            .planner_local_instructions
763            .saturating_add(execute_phase_attribution.store_local_instructions)
764            .saturating_add(execute_phase_attribution.executor_local_instructions);
765        let cache_attribution = compile_cache_attribution.merge(execute_cache_attribution);
766        let total_local_instructions =
767            compile_local_instructions.saturating_add(execute_local_instructions);
768
769        Ok((
770            result,
771            SqlQueryExecutionAttribution {
772                compile_local_instructions,
773                planner_local_instructions: execute_phase_attribution.planner_local_instructions,
774                store_local_instructions: execute_phase_attribution.store_local_instructions,
775                executor_local_instructions: execute_phase_attribution.executor_local_instructions,
776                grouped_stream_local_instructions: execute_phase_attribution
777                    .grouped_stream_local_instructions,
778                grouped_fold_local_instructions: execute_phase_attribution
779                    .grouped_fold_local_instructions,
780                grouped_finalize_local_instructions: execute_phase_attribution
781                    .grouped_finalize_local_instructions,
782                grouped_count_borrowed_hash_computations: execute_phase_attribution
783                    .grouped_count
784                    .borrowed_hash_computations,
785                grouped_count_bucket_candidate_checks: execute_phase_attribution
786                    .grouped_count
787                    .bucket_candidate_checks,
788                grouped_count_existing_group_hits: execute_phase_attribution
789                    .grouped_count
790                    .existing_group_hits,
791                grouped_count_new_group_inserts: execute_phase_attribution
792                    .grouped_count
793                    .new_group_inserts,
794                grouped_count_row_materialization_local_instructions: execute_phase_attribution
795                    .grouped_count
796                    .row_materialization_local_instructions,
797                grouped_count_group_lookup_local_instructions: execute_phase_attribution
798                    .grouped_count
799                    .group_lookup_local_instructions,
800                grouped_count_existing_group_update_local_instructions: execute_phase_attribution
801                    .grouped_count
802                    .existing_group_update_local_instructions,
803                grouped_count_new_group_insert_local_instructions: execute_phase_attribution
804                    .grouped_count
805                    .new_group_insert_local_instructions,
806                pure_covering_decode_local_instructions,
807                pure_covering_row_assembly_local_instructions,
808                store_get_calls,
809                response_decode_local_instructions: 0,
810                execute_local_instructions,
811                total_local_instructions,
812                sql_compiled_command_cache_hits: cache_attribution.sql_compiled_command_cache_hits,
813                sql_compiled_command_cache_misses: cache_attribution
814                    .sql_compiled_command_cache_misses,
815                sql_select_plan_cache_hits: cache_attribution.sql_select_plan_cache_hits,
816                sql_select_plan_cache_misses: cache_attribution.sql_select_plan_cache_misses,
817                shared_query_plan_cache_hits: cache_attribution.shared_query_plan_cache_hits,
818                shared_query_plan_cache_misses: cache_attribution.shared_query_plan_cache_misses,
819            },
820        ))
821    }
822
823    /// Execute one single-entity reduced SQL mutation statement.
824    ///
825    /// This surface stays hard-bound to `E`, rejects read-only SQL, and
826    /// returns SQL-shaped mutation output such as counts or `RETURNING` rows.
827    pub fn execute_sql_update<E>(&self, sql: &str) -> Result<SqlStatementResult, QueryError>
828    where
829        E: PersistedRow<Canister = C> + EntityValue,
830    {
831        let compiled = self.compile_sql_update::<E>(sql)?;
832
833        self.execute_compiled_sql::<E>(&compiled)
834    }
835
836    // Compile one SQL query-surface string into the session-owned generic-free
837    // semantic command artifact before execution.
838    pub(in crate::db) fn compile_sql_query<E>(
839        &self,
840        sql: &str,
841    ) -> Result<CompiledSqlCommand, QueryError>
842    where
843        E: PersistedRow<Canister = C> + EntityValue,
844    {
845        self.compile_sql_query_with_cache_attribution::<E>(sql)
846            .map(|(compiled, _)| compiled)
847    }
848
849    fn compile_sql_query_with_cache_attribution<E>(
850        &self,
851        sql: &str,
852    ) -> Result<(CompiledSqlCommand, SqlCacheAttribution), QueryError>
853    where
854        E: PersistedRow<Canister = C> + EntityValue,
855    {
856        self.compile_sql_statement_with_cache::<E>(
857            SqlCompiledCommandCacheKey::query_for_entity::<E>(sql),
858            sql,
859            Self::ensure_sql_query_statement_supported,
860        )
861    }
862
863    // Compile one SQL update-surface string into the session-owned generic-free
864    // semantic command artifact before execution.
865    pub(in crate::db) fn compile_sql_update<E>(
866        &self,
867        sql: &str,
868    ) -> Result<CompiledSqlCommand, QueryError>
869    where
870        E: PersistedRow<Canister = C> + EntityValue,
871    {
872        self.compile_sql_update_with_cache_attribution::<E>(sql)
873            .map(|(compiled, _)| compiled)
874    }
875
876    fn compile_sql_update_with_cache_attribution<E>(
877        &self,
878        sql: &str,
879    ) -> Result<(CompiledSqlCommand, SqlCacheAttribution), QueryError>
880    where
881        E: PersistedRow<Canister = C> + EntityValue,
882    {
883        self.compile_sql_statement_with_cache::<E>(
884            SqlCompiledCommandCacheKey::update_for_entity::<E>(sql),
885            sql,
886            Self::ensure_sql_update_statement_supported,
887        )
888    }
889
890    // Reuse one previously compiled SQL artifact when the session-local cache
891    // can prove the surface, entity contract, and raw SQL text all match.
892    fn compile_sql_statement_with_cache<E>(
893        &self,
894        cache_key: SqlCompiledCommandCacheKey,
895        sql: &str,
896        ensure_surface_supported: fn(&SqlStatement) -> Result<(), QueryError>,
897    ) -> Result<(CompiledSqlCommand, SqlCacheAttribution), QueryError>
898    where
899        E: PersistedRow<Canister = C> + EntityValue,
900    {
901        {
902            let cached =
903                self.with_sql_compiled_command_cache(|cache| cache.get(&cache_key).cloned());
904            if let Some(compiled) = cached {
905                return Ok((
906                    compiled,
907                    SqlCacheAttribution::sql_compiled_command_cache_hit(),
908                ));
909            }
910        }
911
912        let parsed = parse_sql_statement(sql)?;
913        ensure_surface_supported(&parsed)?;
914        let compiled = Self::compile_sql_statement_for_authority(
915            &parsed,
916            EntityAuthority::for_type::<E>(),
917            cache_key.clone(),
918        )?;
919
920        self.with_sql_compiled_command_cache(|cache| {
921            cache.insert(cache_key, compiled.clone());
922        });
923
924        Ok((
925            compiled,
926            SqlCacheAttribution::sql_compiled_command_cache_miss(),
927        ))
928    }
929}