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