Skip to main content

icydb_core/db/session/sql/dispatch/
mod.rs

1//! Module: db::session::sql::dispatch
2//! Responsibility: module-local ownership and contracts for db::session::sql::dispatch.
3//! Does not own: cross-module orchestration outside this module.
4//! Boundary: exposes this module API while keeping implementation details internal.
5
6mod computed;
7mod lowered;
8
9use crate::{
10    db::{
11        DbSession, MissingRowPolicy, PersistedRow, Query, QueryError,
12        executor::{EntityAuthority, execute_sql_projection_rows_for_canister},
13        identifiers_tail_match,
14        query::intent::StructuralQuery,
15        session::sql::{
16            SqlDispatchResult, SqlParsedStatement, SqlStatementRoute,
17            aggregate::{
18                SqlAggregateSurface, parsed_requires_dedicated_sql_aggregate_lane,
19                unsupported_sql_aggregate_lane_message,
20            },
21            computed_projection,
22            projection::{
23                SqlProjectionPayload, projection_labels_from_entity_model,
24                projection_labels_from_projection_spec, sql_projection_rows_from_kernel_rows,
25            },
26        },
27        sql::lowering::{
28            LoweredSqlCommand, LoweredSqlQuery, bind_lowered_sql_query,
29            lower_sql_command_from_prepared_statement,
30        },
31    },
32    traits::{CanisterKind, EntityKind, EntityValue},
33};
34
35///
36/// GeneratedSqlDispatchAttempt
37///
38/// Hidden generated-query dispatch envelope used by the facade helper to keep
39/// generated route ownership in core while preserving the public EXPLAIN error
40/// rewrite contract at the outer boundary.
41///
42
43#[doc(hidden)]
44pub struct GeneratedSqlDispatchAttempt {
45    entity_name: &'static str,
46    explain_order_field: Option<&'static str>,
47    result: Result<SqlDispatchResult, QueryError>,
48}
49
50impl GeneratedSqlDispatchAttempt {
51    // Build one generated-query dispatch attempt with optional explain-hint context.
52    const fn new(
53        entity_name: &'static str,
54        explain_order_field: Option<&'static str>,
55        result: Result<SqlDispatchResult, QueryError>,
56    ) -> Self {
57        Self {
58            entity_name,
59            explain_order_field,
60            result,
61        }
62    }
63
64    /// Borrow the resolved entity name for this generated-query attempt.
65    #[must_use]
66    pub const fn entity_name(&self) -> &'static str {
67        self.entity_name
68    }
69
70    /// Borrow the suggested deterministic order field for EXPLAIN rewrites.
71    #[must_use]
72    pub const fn explain_order_field(&self) -> Option<&'static str> {
73        self.explain_order_field
74    }
75
76    /// Consume and return the generated-query dispatch result.
77    pub fn into_result(self) -> Result<SqlDispatchResult, QueryError> {
78        self.result
79    }
80}
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub(in crate::db::session::sql) enum SqlGroupingSurface {
84    Scalar,
85    Dispatch,
86    GeneratedQuerySurface,
87    Grouped,
88}
89
90const fn unsupported_sql_grouping_message(surface: SqlGroupingSurface) -> &'static str {
91    match surface {
92        SqlGroupingSurface::Scalar => {
93            "execute_sql rejects grouped SELECT; use execute_sql_grouped(...)"
94        }
95        SqlGroupingSurface::Dispatch => {
96            "execute_sql_dispatch rejects grouped SELECT execution; use execute_sql_grouped(...)"
97        }
98        SqlGroupingSurface::GeneratedQuerySurface => {
99            "generated SQL query surface rejects grouped SELECT execution; use execute_sql_grouped(...)"
100        }
101        SqlGroupingSurface::Grouped => "execute_sql_grouped requires grouped SQL query intent",
102    }
103}
104
105// Enforce the generated canister query contract that empty SQL is unsupported
106// before any parser/lowering work occurs.
107fn trim_generated_query_sql_input(sql: &str) -> Result<&str, QueryError> {
108    let sql_trimmed = sql.trim();
109    if sql_trimmed.is_empty() {
110        return Err(QueryError::unsupported_query(
111            "query endpoint requires a non-empty SQL string",
112        ));
113    }
114
115    Ok(sql_trimmed)
116}
117
118// Render the generated-surface entity list from the descriptor table instead
119// of assuming every session-visible entity belongs on the public query export.
120fn generated_sql_entities(authorities: &[EntityAuthority]) -> Vec<String> {
121    let mut entities = Vec::with_capacity(authorities.len());
122
123    for authority in authorities {
124        entities.push(authority.model().name().to_string());
125    }
126
127    entities
128}
129
130// Resolve one generated query route onto the descriptor-owned authority table.
131fn authority_for_generated_sql_route(
132    route: &SqlStatementRoute,
133    authorities: &[EntityAuthority],
134) -> Result<EntityAuthority, QueryError> {
135    let sql_entity = route.entity();
136
137    for authority in authorities {
138        if identifiers_tail_match(sql_entity, authority.model().name()) {
139            return Ok(*authority);
140        }
141    }
142
143    Err(unsupported_generated_sql_entity_error(
144        sql_entity,
145        authorities,
146    ))
147}
148
149// Keep the generated query-surface unsupported-entity contract stable while
150// moving authority lookup out of the build-generated shim.
151fn unsupported_generated_sql_entity_error(
152    entity_name: &str,
153    authorities: &[EntityAuthority],
154) -> QueryError {
155    let mut supported = String::new();
156
157    for (index, authority) in authorities.iter().enumerate() {
158        if index != 0 {
159            supported.push_str(", ");
160        }
161
162        supported.push_str(authority.model().name());
163    }
164
165    QueryError::unsupported_query(format!(
166        "query endpoint does not support entity '{entity_name}'; supported: {supported}"
167    ))
168}
169
170impl<C: CanisterKind> DbSession<C> {
171    // Execute one structural SQL load query and return only row-oriented SQL
172    // projection values, keeping typed projection rows out of the shared SQL
173    // query-lane path.
174    fn execute_structural_sql_projection(
175        &self,
176        query: StructuralQuery,
177        authority: EntityAuthority,
178    ) -> Result<SqlProjectionPayload, QueryError> {
179        // Phase 1: build the structural access plan once and reuse its
180        // projection contract for both labels and row materialization.
181        let plan = query.build_plan()?;
182        let projection = plan.projection_spec(authority.model());
183        let columns = projection_labels_from_projection_spec(&projection);
184
185        // Phase 2: execute the shared structural load path with the already
186        // derived projection semantics.
187        let projected = execute_sql_projection_rows_for_canister(
188            &self.db,
189            self.debug,
190            authority.model(),
191            projection,
192            authority,
193            plan,
194        )
195        .map_err(QueryError::execute)?;
196        let (rows, row_count) = projected.into_parts();
197
198        Ok(SqlProjectionPayload::new(columns, rows, row_count))
199    }
200
201    // Execute one typed SQL delete query while keeping the row payload on the
202    // typed delete executor boundary that still owns non-runtime-hook delete
203    // commit-window application.
204    fn execute_typed_sql_delete<E>(&self, query: &Query<E>) -> Result<SqlDispatchResult, QueryError>
205    where
206        E: PersistedRow<Canister = C> + EntityValue,
207    {
208        let plan = query.plan()?.into_executable();
209        let deleted = self
210            .with_metrics(|| self.delete_executor::<E>().execute_sql_projection(plan))
211            .map_err(QueryError::execute)?;
212        let (rows, row_count) = deleted.into_parts();
213        let rows = sql_projection_rows_from_kernel_rows(rows);
214
215        Ok(SqlProjectionPayload::new(
216            projection_labels_from_entity_model(E::MODEL),
217            rows,
218            row_count,
219        )
220        .into_dispatch_result())
221    }
222
223    // Validate that one SQL-derived query intent matches the grouped/scalar
224    // execution surface that is about to consume it.
225    pub(in crate::db::session::sql) fn ensure_sql_query_grouping<E>(
226        query: &Query<E>,
227        surface: SqlGroupingSurface,
228    ) -> Result<(), QueryError>
229    where
230        E: EntityKind,
231    {
232        match (surface, query.has_grouping()) {
233            (
234                SqlGroupingSurface::Scalar
235                | SqlGroupingSurface::Dispatch
236                | SqlGroupingSurface::GeneratedQuerySurface,
237                false,
238            )
239            | (SqlGroupingSurface::Grouped, true) => Ok(()),
240            (
241                SqlGroupingSurface::Scalar
242                | SqlGroupingSurface::Dispatch
243                | SqlGroupingSurface::GeneratedQuerySurface,
244                true,
245            )
246            | (SqlGroupingSurface::Grouped, false) => Err(QueryError::unsupported_query(
247                unsupported_sql_grouping_message(surface),
248            )),
249        }
250    }
251
252    // Validate one lowered shared SQL query shape against the grouped/scalar
253    // contract for surfaces that do not materialize a typed `Query<E>`.
254    pub(in crate::db::session::sql) fn ensure_lowered_sql_query_grouping(
255        lowered: &LoweredSqlCommand,
256        surface: SqlGroupingSurface,
257    ) -> Result<(), QueryError> {
258        let Some(query) = lowered.query() else {
259            return Ok(());
260        };
261
262        match (surface, query.has_grouping()) {
263            (
264                SqlGroupingSurface::Scalar
265                | SqlGroupingSurface::Dispatch
266                | SqlGroupingSurface::GeneratedQuerySurface,
267                false,
268            )
269            | (SqlGroupingSurface::Grouped, true) => Ok(()),
270            (
271                SqlGroupingSurface::Scalar
272                | SqlGroupingSurface::Dispatch
273                | SqlGroupingSurface::GeneratedQuerySurface,
274                true,
275            )
276            | (SqlGroupingSurface::Grouped, false) => Err(QueryError::unsupported_query(
277                unsupported_sql_grouping_message(surface),
278            )),
279        }
280    }
281
282    /// Execute one reduced SQL statement into one unified SQL dispatch payload.
283    pub fn execute_sql_dispatch<E>(&self, sql: &str) -> Result<SqlDispatchResult, QueryError>
284    where
285        E: PersistedRow<Canister = C> + EntityValue,
286    {
287        let parsed = self.parse_sql_statement(sql)?;
288
289        self.execute_sql_dispatch_parsed::<E>(&parsed)
290    }
291
292    /// Execute one parsed reduced SQL statement into one unified SQL payload.
293    pub fn execute_sql_dispatch_parsed<E>(
294        &self,
295        parsed: &SqlParsedStatement,
296    ) -> Result<SqlDispatchResult, QueryError>
297    where
298        E: PersistedRow<Canister = C> + EntityValue,
299    {
300        match parsed.route() {
301            SqlStatementRoute::Query { .. } => {
302                if parsed_requires_dedicated_sql_aggregate_lane(parsed) {
303                    return Err(QueryError::unsupported_query(
304                        unsupported_sql_aggregate_lane_message(
305                            SqlAggregateSurface::ExecuteSqlDispatch,
306                        ),
307                    ));
308                }
309
310                if let Some(plan) =
311                    computed_projection::computed_sql_projection_plan(&parsed.statement)?
312                {
313                    return self.execute_computed_sql_projection_dispatch::<E>(plan);
314                }
315
316                // Phase 1: keep typed dispatch on the shared lowered query lane
317                // for plain `SELECT`, and only pay typed query binding on the
318                // `DELETE` branch that still owns typed commit semantics.
319                let lowered = parsed
320                    .lower_query_lane_for_entity(E::MODEL.name(), E::MODEL.primary_key.name)?;
321
322                Self::ensure_lowered_sql_query_grouping(&lowered, SqlGroupingSurface::Dispatch)?;
323
324                // Phase 2: dispatch `SELECT` directly from the lowered shape so
325                // typed SQL projection does not rebuild and discard a typed
326                // `Query<E>` before returning to the structural executor path.
327                match lowered.query() {
328                    Some(LoweredSqlQuery::Select(select)) => self
329                        .execute_lowered_sql_dispatch_select_core(
330                            select,
331                            EntityAuthority::for_type::<E>(),
332                        ),
333                    Some(LoweredSqlQuery::Delete(delete)) => {
334                        let typed_query = bind_lowered_sql_query::<E>(
335                            LoweredSqlQuery::Delete(delete.clone()),
336                            MissingRowPolicy::Ignore,
337                        )
338                        .map_err(QueryError::from_sql_lowering_error)?;
339
340                        self.execute_typed_sql_delete(&typed_query)
341                    }
342                    None => Err(QueryError::unsupported_query(
343                        "execute_sql_dispatch accepts SELECT or DELETE only",
344                    )),
345                }
346            }
347            SqlStatementRoute::Explain { .. } => {
348                if let Some((mode, plan)) =
349                    computed_projection::computed_sql_projection_explain_plan(&parsed.statement)?
350                {
351                    return Self::explain_computed_sql_projection_dispatch::<E>(mode, plan)
352                        .map(SqlDispatchResult::Explain);
353                }
354
355                let lowered = lower_sql_command_from_prepared_statement(
356                    parsed.prepare(E::MODEL.name())?,
357                    E::MODEL.primary_key.name,
358                )
359                .map_err(QueryError::from_sql_lowering_error)?;
360                if let Some(explain) = self.explain_lowered_sql_execution_for_authority(
361                    &lowered,
362                    EntityAuthority::for_type::<E>(),
363                )? {
364                    return Ok(SqlDispatchResult::Explain(explain));
365                }
366
367                lowered
368                    .explain_for_model(E::MODEL)
369                    .map(SqlDispatchResult::Explain)
370            }
371            SqlStatementRoute::Describe { .. } => {
372                Ok(SqlDispatchResult::Describe(self.describe_entity::<E>()))
373            }
374            SqlStatementRoute::ShowIndexes { .. } => {
375                Ok(SqlDispatchResult::ShowIndexes(self.show_indexes::<E>()))
376            }
377            SqlStatementRoute::ShowColumns { .. } => {
378                Ok(SqlDispatchResult::ShowColumns(self.show_columns::<E>()))
379            }
380            SqlStatementRoute::ShowEntities => {
381                Ok(SqlDispatchResult::ShowEntities(self.show_entities()))
382            }
383        }
384    }
385
386    /// Execute one parsed reduced SQL statement through the generated canister
387    /// query/explain surface for one already-resolved dynamic authority.
388    ///
389    /// This keeps the canister SQL facade on the same reduced SQL ownership
390    /// boundary as typed dispatch without forcing the outer facade to reopen
391    /// typed-generic routing just to preserve parity for computed projections.
392    #[doc(hidden)]
393    pub fn execute_generated_query_surface_dispatch_for_authority(
394        &self,
395        parsed: &SqlParsedStatement,
396        authority: EntityAuthority,
397    ) -> Result<SqlDispatchResult, QueryError> {
398        match parsed.route() {
399            SqlStatementRoute::Query { .. } => {
400                if parsed_requires_dedicated_sql_aggregate_lane(parsed) {
401                    return Err(QueryError::unsupported_query(
402                        unsupported_sql_aggregate_lane_message(
403                            SqlAggregateSurface::GeneratedQuerySurface,
404                        ),
405                    ));
406                }
407
408                if let Some(plan) =
409                    computed_projection::computed_sql_projection_plan(&parsed.statement)?
410                {
411                    return self
412                        .execute_computed_sql_projection_dispatch_for_authority(plan, authority);
413                }
414
415                let lowered = parsed.lower_query_lane_for_entity(
416                    authority.model().name(),
417                    authority.model().primary_key.name,
418                )?;
419
420                Self::ensure_lowered_sql_query_grouping(
421                    &lowered,
422                    SqlGroupingSurface::GeneratedQuerySurface,
423                )?;
424
425                self.execute_lowered_sql_dispatch_query_for_authority(&lowered, authority)
426            }
427            SqlStatementRoute::Explain { .. } => {
428                if let Some((mode, plan)) =
429                    computed_projection::computed_sql_projection_explain_plan(&parsed.statement)?
430                {
431                    return Self::explain_computed_sql_projection_dispatch_for_authority(
432                        mode, plan, authority,
433                    )
434                    .map(SqlDispatchResult::Explain);
435                }
436
437                let lowered = parsed.lower_query_lane_for_entity(
438                    authority.model().name(),
439                    authority.model().primary_key.name,
440                )?;
441                if let Some(explain) =
442                    self.explain_lowered_sql_execution_for_authority(&lowered, authority)?
443                {
444                    return Ok(SqlDispatchResult::Explain(explain));
445                }
446
447                lowered
448                    .explain_for_model(authority.model())
449                    .map(SqlDispatchResult::Explain)
450            }
451            SqlStatementRoute::Describe { .. }
452            | SqlStatementRoute::ShowIndexes { .. }
453            | SqlStatementRoute::ShowColumns { .. }
454            | SqlStatementRoute::ShowEntities => Err(QueryError::unsupported_query(
455                "generated SQL query surface requires query or EXPLAIN statement lanes",
456            )),
457        }
458    }
459
460    /// Execute one raw SQL string through the generated canister query surface.
461    ///
462    /// This hidden helper keeps parse, route, authority, and metadata/query
463    /// dispatch ownership in core so the build-generated `sql_dispatch` shim
464    /// stays close to a pure descriptor table plus public ABI wrapper.
465    #[doc(hidden)]
466    #[must_use]
467    pub fn execute_generated_query_surface_sql(
468        &self,
469        sql: &str,
470        authorities: &[EntityAuthority],
471    ) -> GeneratedSqlDispatchAttempt {
472        // Phase 1: normalize and parse once so every generated route family
473        // shares the same SQL ownership boundary.
474        let sql_trimmed = match trim_generated_query_sql_input(sql) {
475            Ok(sql_trimmed) => sql_trimmed,
476            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
477        };
478        let parsed = match self.parse_sql_statement(sql_trimmed) {
479            Ok(parsed) => parsed,
480            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
481        };
482
483        // Phase 2: keep SHOW ENTITIES descriptor-owned and resolve all other
484        // generated routes against the emitted authority table exactly once.
485        if matches!(parsed.route(), SqlStatementRoute::ShowEntities) {
486            return GeneratedSqlDispatchAttempt::new(
487                "",
488                None,
489                Ok(SqlDispatchResult::ShowEntities(generated_sql_entities(
490                    authorities,
491                ))),
492            );
493        }
494        let authority = match authority_for_generated_sql_route(parsed.route(), authorities) {
495            Ok(authority) => authority,
496            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
497        };
498
499        // Phase 3: dispatch the resolved route through the existing query,
500        // explain, and metadata helpers without rebuilding route ownership in
501        // the generated build output.
502        let entity_name = authority.model().name();
503        let explain_order_field = parsed
504            .route()
505            .is_explain()
506            .then_some(authority.model().primary_key.name);
507        let result = match parsed.route() {
508            SqlStatementRoute::Query { .. } | SqlStatementRoute::Explain { .. } => {
509                self.execute_generated_query_surface_dispatch_for_authority(&parsed, authority)
510            }
511            SqlStatementRoute::Describe { .. } => Ok(SqlDispatchResult::Describe(
512                self.describe_entity_model(authority.model()),
513            )),
514            SqlStatementRoute::ShowIndexes { .. } => Ok(SqlDispatchResult::ShowIndexes(
515                self.show_indexes_for_model(authority.model()),
516            )),
517            SqlStatementRoute::ShowColumns { .. } => Ok(SqlDispatchResult::ShowColumns(
518                self.show_columns_for_model(authority.model()),
519            )),
520            SqlStatementRoute::ShowEntities => unreachable!(
521                "SHOW ENTITIES is handled before authority resolution for generated query dispatch"
522            ),
523        };
524
525        GeneratedSqlDispatchAttempt::new(entity_name, explain_order_field, result)
526    }
527}