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::sql::projection::projection_labels_from_projection_spec,
29        sql::lowering::{LoweredBaseQueryShape, LoweredSqlCommand, SqlGlobalAggregateCommandCore},
30        sql::parser::{SqlStatement, parse_sql},
31    },
32    traits::{CanisterKind, EntityValue},
33};
34
35#[cfg(test)]
36use crate::db::{
37    MissingRowPolicy, PagedGroupedExecutionWithTrace,
38    sql::lowering::{
39        bind_lowered_sql_query, lower_sql_command_from_prepared_statement, prepare_sql_statement,
40    },
41};
42
43#[cfg(feature = "structural-read-metrics")]
44pub use crate::db::session::sql::projection::{
45    SqlProjectionMaterializationMetrics, with_sql_projection_materialization_metrics,
46};
47
48/// Unified SQL statement payload returned by shared SQL lane execution.
49#[derive(Debug)]
50pub enum SqlStatementResult {
51    Count {
52        row_count: u32,
53    },
54    Projection {
55        columns: Vec<String>,
56        rows: Vec<Vec<crate::value::Value>>,
57        row_count: u32,
58    },
59    ProjectionText {
60        columns: Vec<String>,
61        rows: Vec<Vec<String>>,
62        row_count: u32,
63    },
64    Grouped {
65        columns: Vec<String>,
66        rows: Vec<GroupedRow>,
67        row_count: u32,
68        next_cursor: Option<String>,
69    },
70    Explain(String),
71    Describe(crate::db::EntitySchemaDescription),
72    ShowIndexes(Vec<String>),
73    ShowColumns(Vec<crate::db::EntityFieldDescription>),
74    ShowEntities(Vec<String>),
75}
76
77///
78/// SqlQueryExecutionAttribution
79///
80/// SqlQueryExecutionAttribution records the top-level reduced SQL query cost
81/// split at the new compile/execute seam.
82/// This keeps future cache validation focused on one concrete question:
83/// whether repeated queries stop paying compile cost while execute cost stays
84/// otherwise comparable.
85///
86
87#[cfg(feature = "perf-attribution")]
88#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
89pub struct SqlQueryExecutionAttribution {
90    pub compile_local_instructions: u64,
91    pub execute_local_instructions: u64,
92    pub total_local_instructions: u64,
93}
94
95#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
96enum SqlCompiledCommandSurface {
97    Query,
98    Update,
99}
100
101///
102/// SqlCompiledCommandCacheKey
103///
104/// SqlCompiledCommandCacheKey pins one compiled SQL artifact to the exact
105/// session-local semantic boundary that produced it.
106/// The key is intentionally conservative: surface kind, entity path, schema
107/// fingerprint, and raw SQL text must all match before execution can reuse a
108/// prior compile result.
109///
110
111#[derive(Clone, Debug, Eq, Hash, PartialEq)]
112pub(in crate::db) struct SqlCompiledCommandCacheKey {
113    surface: SqlCompiledCommandSurface,
114    entity_path: &'static str,
115    schema_fingerprint: CommitSchemaFingerprint,
116    sql: String,
117}
118
119#[derive(Clone, Debug, Eq, Hash, PartialEq)]
120pub(in crate::db) struct SqlSelectPlanCacheKey {
121    compiled: SqlCompiledCommandCacheKey,
122    visibility: crate::db::session::query::QueryPlanVisibility,
123}
124
125///
126/// SqlSelectPlanCacheEntry
127///
128/// SqlSelectPlanCacheEntry keeps the session-owned SQL projection contract
129/// beside the already planned structural SELECT access plan.
130/// This lets repeated SQL execution reuse both planner output and outward
131/// column-label derivation without caching executor-owned runtime state.
132///
133
134#[derive(Clone, Debug)]
135pub(in crate::db) struct SqlSelectPlanCacheEntry {
136    plan: AccessPlannedQuery,
137    columns: Vec<String>,
138}
139
140impl SqlSelectPlanCacheEntry {
141    #[must_use]
142    pub(in crate::db) const fn new(plan: AccessPlannedQuery, columns: Vec<String>) -> Self {
143        Self { plan, columns }
144    }
145
146    #[must_use]
147    pub(in crate::db) fn into_parts(self) -> (AccessPlannedQuery, Vec<String>) {
148        (self.plan, self.columns)
149    }
150}
151
152impl SqlCompiledCommandCacheKey {
153    fn query_for_entity<E>(sql: &str) -> Self
154    where
155        E: PersistedRow + EntityValue,
156    {
157        Self::for_entity::<E>(SqlCompiledCommandSurface::Query, sql)
158    }
159
160    fn update_for_entity<E>(sql: &str) -> Self
161    where
162        E: PersistedRow + EntityValue,
163    {
164        Self::for_entity::<E>(SqlCompiledCommandSurface::Update, sql)
165    }
166
167    fn for_entity<E>(surface: SqlCompiledCommandSurface, sql: &str) -> Self
168    where
169        E: PersistedRow + EntityValue,
170    {
171        Self {
172            surface,
173            entity_path: E::PATH,
174            schema_fingerprint: commit_schema_fingerprint_for_entity::<E>(),
175            sql: sql.to_string(),
176        }
177    }
178
179    #[must_use]
180    pub(in crate::db) const fn entity_path(&self) -> &'static str {
181        self.entity_path
182    }
183
184    #[must_use]
185    pub(in crate::db) const fn schema_fingerprint(&self) -> CommitSchemaFingerprint {
186        self.schema_fingerprint
187    }
188}
189
190impl SqlSelectPlanCacheKey {
191    const fn from_compiled_key(
192        compiled: SqlCompiledCommandCacheKey,
193        visibility: crate::db::session::query::QueryPlanVisibility,
194    ) -> Self {
195        Self {
196            compiled,
197            visibility,
198        }
199    }
200}
201
202pub(in crate::db) type SqlCompiledCommandCache =
203    HashMap<SqlCompiledCommandCacheKey, CompiledSqlCommand>;
204pub(in crate::db) type SqlSelectPlanCache = HashMap<SqlSelectPlanCacheKey, SqlSelectPlanCacheEntry>;
205
206// Keep the compile artifact session-owned and generic-free so the SQL surface
207// can separate semantic compilation from execution without coupling the seam to
208// typed entity binding or executor scratch state.
209#[derive(Clone, Debug)]
210pub(in crate::db) enum CompiledSqlCommand {
211    Select {
212        query: StructuralQuery,
213        compiled_cache_key: Option<SqlCompiledCommandCacheKey>,
214    },
215    Delete {
216        query: LoweredBaseQueryShape,
217        statement: SqlDeleteStatement,
218    },
219    GlobalAggregate {
220        command: SqlGlobalAggregateCommandCore,
221        label_override: Option<String>,
222    },
223    Explain(LoweredSqlCommand),
224    Insert(SqlInsertStatement),
225    Update(SqlUpdateStatement),
226    DescribeEntity,
227    ShowIndexesEntity,
228    ShowColumnsEntity,
229    ShowEntities,
230}
231
232// Keep parsing as a module-owned helper instead of hanging a pure parser off
233// `DbSession` as a fake session method.
234pub(in crate::db) fn parse_sql_statement(sql: &str) -> Result<SqlStatement, QueryError> {
235    parse_sql(sql).map_err(QueryError::from_sql_parse_error)
236}
237
238#[cfg(feature = "perf-attribution")]
239#[expect(
240    clippy::missing_const_for_fn,
241    reason = "the wasm32 branch reads the runtime performance counter and cannot be const"
242)]
243fn read_sql_local_instruction_counter() -> u64 {
244    #[cfg(target_arch = "wasm32")]
245    {
246        canic_cdk::api::performance_counter(1)
247    }
248
249    #[cfg(not(target_arch = "wasm32"))]
250    {
251        0
252    }
253}
254
255#[cfg(feature = "perf-attribution")]
256fn measure_sql_stage<T, E>(run: impl FnOnce() -> Result<T, E>) -> (u64, Result<T, E>) {
257    let start = read_sql_local_instruction_counter();
258    let result = run();
259    let delta = read_sql_local_instruction_counter().saturating_sub(start);
260
261    (delta, result)
262}
263
264impl<C: CanisterKind> DbSession<C> {
265    // Lazily allocate one session-local compiled SQL cache so cold sessions do
266    // not pay any map setup cost until SQL compilation is actually used.
267    fn sql_compiled_command_cache(&self) -> &RefCell<SqlCompiledCommandCache> {
268        self.sql_compiled_command_cache
269            .get_or_init(|| RefCell::new(SqlCompiledCommandCache::new()))
270    }
271
272    // Lazily allocate one session-local SELECT plan cache so repeat query
273    // execution can reuse planner output once store visibility is known.
274    fn sql_select_plan_cache(&self) -> &RefCell<SqlSelectPlanCache> {
275        self.sql_select_plan_cache
276            .get_or_init(|| RefCell::new(SqlSelectPlanCache::new()))
277    }
278
279    #[cfg(test)]
280    pub(in crate::db) fn sql_compiled_command_cache_len(&self) -> usize {
281        self.sql_compiled_command_cache().borrow().len()
282    }
283
284    #[cfg(test)]
285    pub(in crate::db) fn sql_select_plan_cache_len(&self) -> usize {
286        self.sql_select_plan_cache().borrow().len()
287    }
288
289    fn planned_sql_select_with_visibility(
290        &self,
291        query: &StructuralQuery,
292        authority: EntityAuthority,
293        compiled_cache_key: Option<&SqlCompiledCommandCacheKey>,
294    ) -> Result<SqlSelectPlanCacheEntry, QueryError> {
295        let visibility = self.query_plan_visibility_for_store_path(authority.store_path())?;
296        let fallback_schema_fingerprint = crate::db::schema::commit_schema_fingerprint_for_model(
297            authority.model().path,
298            authority.model(),
299        );
300        let cache_entity_path = compiled_cache_key.map_or_else(
301            || authority.model().path,
302            SqlCompiledCommandCacheKey::entity_path,
303        );
304        let cache_schema_fingerprint = compiled_cache_key.map_or(
305            fallback_schema_fingerprint,
306            SqlCompiledCommandCacheKey::schema_fingerprint,
307        );
308
309        let Some(compiled_cache_key) = compiled_cache_key else {
310            let plan = self.cached_structural_plan_for_authority(
311                cache_entity_path,
312                cache_schema_fingerprint,
313                authority.store_path(),
314                authority.model(),
315                query,
316            )?;
317            let columns =
318                projection_labels_from_projection_spec(&plan.projection_spec(authority.model()));
319
320            return Ok(SqlSelectPlanCacheEntry::new(plan, columns));
321        };
322
323        let plan_cache_key =
324            SqlSelectPlanCacheKey::from_compiled_key(compiled_cache_key.clone(), visibility);
325        {
326            let cache = self.sql_select_plan_cache().borrow();
327            if let Some(plan) = cache.get(&plan_cache_key) {
328                return Ok(plan.clone());
329            }
330        }
331
332        let plan = self.cached_structural_plan_for_authority(
333            cache_entity_path,
334            cache_schema_fingerprint,
335            authority.store_path(),
336            authority.model(),
337            query,
338        )?;
339        let columns =
340            projection_labels_from_projection_spec(&plan.projection_spec(authority.model()));
341        let entry = SqlSelectPlanCacheEntry::new(plan, columns);
342        self.sql_select_plan_cache()
343            .borrow_mut()
344            .insert(plan_cache_key, entry.clone());
345
346        Ok(entry)
347    }
348
349    // Resolve planner-visible indexes and build one execution-ready
350    // structural plan at the session SQL boundary.
351    pub(in crate::db::session::sql) fn build_structural_plan_with_visible_indexes_for_authority(
352        &self,
353        query: StructuralQuery,
354        authority: EntityAuthority,
355    ) -> Result<(VisibleIndexes<'_>, AccessPlannedQuery), QueryError> {
356        let visible_indexes =
357            self.visible_indexes_for_store_model(authority.store_path(), authority.model())?;
358        let plan = query.build_plan_with_visible_indexes(&visible_indexes)?;
359
360        Ok((visible_indexes, plan))
361    }
362
363    // Keep the public SQL query surface aligned with its name and with
364    // query-shaped canister entrypoints.
365    fn ensure_sql_query_statement_supported(statement: &SqlStatement) -> Result<(), QueryError> {
366        match statement {
367            SqlStatement::Select(_)
368            | SqlStatement::Explain(_)
369            | SqlStatement::Describe(_)
370            | SqlStatement::ShowIndexes(_)
371            | SqlStatement::ShowColumns(_)
372            | SqlStatement::ShowEntities(_) => Ok(()),
373            SqlStatement::Insert(_) => Err(QueryError::unsupported_query(
374                "execute_sql_query rejects INSERT; use execute_sql_update::<E>()",
375            )),
376            SqlStatement::Update(_) => Err(QueryError::unsupported_query(
377                "execute_sql_query rejects UPDATE; use execute_sql_update::<E>()",
378            )),
379            SqlStatement::Delete(_) => Err(QueryError::unsupported_query(
380                "execute_sql_query rejects DELETE; use execute_sql_update::<E>()",
381            )),
382        }
383    }
384
385    // Keep the public SQL mutation surface aligned with state-changing SQL
386    // while preserving one explicit read/introspection owner.
387    fn ensure_sql_update_statement_supported(statement: &SqlStatement) -> Result<(), QueryError> {
388        match statement {
389            SqlStatement::Insert(_) | SqlStatement::Update(_) | SqlStatement::Delete(_) => Ok(()),
390            SqlStatement::Select(_) => Err(QueryError::unsupported_query(
391                "execute_sql_update rejects SELECT; use execute_sql_query::<E>()",
392            )),
393            SqlStatement::Explain(_) => Err(QueryError::unsupported_query(
394                "execute_sql_update rejects EXPLAIN; use execute_sql_query::<E>()",
395            )),
396            SqlStatement::Describe(_) => Err(QueryError::unsupported_query(
397                "execute_sql_update rejects DESCRIBE; use execute_sql_query::<E>()",
398            )),
399            SqlStatement::ShowIndexes(_) => Err(QueryError::unsupported_query(
400                "execute_sql_update rejects SHOW INDEXES; use execute_sql_query::<E>()",
401            )),
402            SqlStatement::ShowColumns(_) => Err(QueryError::unsupported_query(
403                "execute_sql_update rejects SHOW COLUMNS; use execute_sql_query::<E>()",
404            )),
405            SqlStatement::ShowEntities(_) => Err(QueryError::unsupported_query(
406                "execute_sql_update rejects SHOW ENTITIES; use execute_sql_query::<E>()",
407            )),
408        }
409    }
410
411    /// Execute one single-entity reduced SQL query or introspection statement.
412    ///
413    /// This surface stays hard-bound to `E`, rejects state-changing SQL, and
414    /// returns SQL-shaped statement output instead of typed entities.
415    pub fn execute_sql_query<E>(&self, sql: &str) -> Result<SqlStatementResult, QueryError>
416    where
417        E: PersistedRow<Canister = C> + EntityValue,
418    {
419        let compiled = self.compile_sql_query::<E>(sql)?;
420
421        self.execute_compiled_sql::<E>(&compiled)
422    }
423
424    /// Execute one reduced SQL query while reporting the compile/execute split
425    /// at the top-level SQL seam.
426    #[cfg(feature = "perf-attribution")]
427    #[doc(hidden)]
428    pub fn execute_sql_query_with_attribution<E>(
429        &self,
430        sql: &str,
431    ) -> Result<(SqlStatementResult, SqlQueryExecutionAttribution), QueryError>
432    where
433        E: PersistedRow<Canister = C> + EntityValue,
434    {
435        // Phase 1: measure the compile side of the new seam, including parse,
436        // surface validation, and semantic command construction.
437        let (compile_local_instructions, compiled) =
438            measure_sql_stage(|| self.compile_sql_query::<E>(sql));
439        let compiled = compiled?;
440
441        // Phase 2: measure the execute side separately so repeat-run cache
442        // experiments can prove which side actually moved.
443        let (execute_local_instructions, result) =
444            measure_sql_stage(|| self.execute_compiled_sql::<E>(&compiled));
445        let result = result?;
446        let total_local_instructions =
447            compile_local_instructions.saturating_add(execute_local_instructions);
448
449        Ok((
450            result,
451            SqlQueryExecutionAttribution {
452                compile_local_instructions,
453                execute_local_instructions,
454                total_local_instructions,
455            },
456        ))
457    }
458
459    /// Execute one single-entity reduced SQL mutation statement.
460    ///
461    /// This surface stays hard-bound to `E`, rejects read-only SQL, and
462    /// returns SQL-shaped mutation output such as counts or `RETURNING` rows.
463    pub fn execute_sql_update<E>(&self, sql: &str) -> Result<SqlStatementResult, QueryError>
464    where
465        E: PersistedRow<Canister = C> + EntityValue,
466    {
467        let compiled = self.compile_sql_update::<E>(sql)?;
468
469        self.execute_compiled_sql::<E>(&compiled)
470    }
471
472    #[cfg(test)]
473    pub(in crate::db) fn execute_grouped_sql_query_for_tests<E>(
474        &self,
475        sql: &str,
476        cursor_token: Option<&str>,
477    ) -> Result<PagedGroupedExecutionWithTrace, QueryError>
478    where
479        E: PersistedRow<Canister = C> + EntityValue,
480    {
481        let parsed = parse_sql_statement(sql)?;
482
483        let lowered = lower_sql_command_from_prepared_statement(
484            prepare_sql_statement(parsed, E::MODEL.name())
485                .map_err(QueryError::from_sql_lowering_error)?,
486            E::MODEL.primary_key.name,
487        )
488        .map_err(QueryError::from_sql_lowering_error)?;
489        let Some(query) = lowered.query().cloned() else {
490            return Err(QueryError::unsupported_query(
491                "grouped SELECT helper requires grouped SELECT",
492            ));
493        };
494        let query = bind_lowered_sql_query::<E>(query, MissingRowPolicy::Ignore)
495            .map_err(QueryError::from_sql_lowering_error)?;
496        if !query.has_grouping() {
497            return Err(QueryError::unsupported_query(
498                "grouped SELECT helper requires grouped SELECT",
499            ));
500        }
501
502        self.execute_grouped(&query, cursor_token)
503    }
504
505    // Compile one SQL query-surface string into the session-owned generic-free
506    // semantic command artifact before execution.
507    pub(in crate::db) fn compile_sql_query<E>(
508        &self,
509        sql: &str,
510    ) -> Result<CompiledSqlCommand, QueryError>
511    where
512        E: PersistedRow<Canister = C> + EntityValue,
513    {
514        self.compile_sql_statement_with_cache::<E>(
515            SqlCompiledCommandCacheKey::query_for_entity::<E>(sql),
516            sql,
517            Self::ensure_sql_query_statement_supported,
518        )
519    }
520
521    // Compile one SQL update-surface string into the session-owned generic-free
522    // semantic command artifact before execution.
523    pub(in crate::db) fn compile_sql_update<E>(
524        &self,
525        sql: &str,
526    ) -> Result<CompiledSqlCommand, QueryError>
527    where
528        E: PersistedRow<Canister = C> + EntityValue,
529    {
530        self.compile_sql_statement_with_cache::<E>(
531            SqlCompiledCommandCacheKey::update_for_entity::<E>(sql),
532            sql,
533            Self::ensure_sql_update_statement_supported,
534        )
535    }
536
537    // Reuse one previously compiled SQL artifact when the session-local cache
538    // can prove the surface, entity contract, and raw SQL text all match.
539    fn compile_sql_statement_with_cache<E>(
540        &self,
541        cache_key: SqlCompiledCommandCacheKey,
542        sql: &str,
543        ensure_surface_supported: fn(&SqlStatement) -> Result<(), QueryError>,
544    ) -> Result<CompiledSqlCommand, QueryError>
545    where
546        E: PersistedRow<Canister = C> + EntityValue,
547    {
548        {
549            let cache = self.sql_compiled_command_cache().borrow();
550            if let Some(compiled) = cache.get(&cache_key) {
551                return Ok(compiled.clone());
552            }
553        }
554
555        let parsed = parse_sql_statement(sql)?;
556        ensure_surface_supported(&parsed)?;
557        let mut compiled = Self::compile_sql_statement_inner::<E>(&parsed)?;
558        if let CompiledSqlCommand::Select {
559            compiled_cache_key, ..
560        } = &mut compiled
561        {
562            *compiled_cache_key = Some(cache_key.clone());
563        }
564
565        self.sql_compiled_command_cache()
566            .borrow_mut()
567            .insert(cache_key, compiled.clone());
568
569        Ok(compiled)
570    }
571
572    // Compile one already-parsed SQL statement into the session-owned semantic
573    // command artifact used by the explicit compile -> execute seam.
574    pub(in crate::db) fn compile_sql_statement_inner<E>(
575        sql_statement: &SqlStatement,
576    ) -> Result<CompiledSqlCommand, QueryError>
577    where
578        E: PersistedRow<Canister = C> + EntityValue,
579    {
580        Self::compile_sql_statement_for_authority(sql_statement, EntityAuthority::for_type::<E>())
581    }
582}