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