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