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
361                lowered
362                    .explain_for_model(E::MODEL)
363                    .map(SqlDispatchResult::Explain)
364            }
365            SqlStatementRoute::Describe { .. } => {
366                Ok(SqlDispatchResult::Describe(self.describe_entity::<E>()))
367            }
368            SqlStatementRoute::ShowIndexes { .. } => {
369                Ok(SqlDispatchResult::ShowIndexes(self.show_indexes::<E>()))
370            }
371            SqlStatementRoute::ShowColumns { .. } => {
372                Ok(SqlDispatchResult::ShowColumns(self.show_columns::<E>()))
373            }
374            SqlStatementRoute::ShowEntities => {
375                Ok(SqlDispatchResult::ShowEntities(self.show_entities()))
376            }
377        }
378    }
379
380    /// Execute one parsed reduced SQL statement through the generated canister
381    /// query/explain surface for one already-resolved dynamic authority.
382    ///
383    /// This keeps the canister SQL facade on the same reduced SQL ownership
384    /// boundary as typed dispatch without forcing the outer facade to reopen
385    /// typed-generic routing just to preserve parity for computed projections.
386    #[doc(hidden)]
387    pub fn execute_generated_query_surface_dispatch_for_authority(
388        &self,
389        parsed: &SqlParsedStatement,
390        authority: EntityAuthority,
391    ) -> Result<SqlDispatchResult, QueryError> {
392        match parsed.route() {
393            SqlStatementRoute::Query { .. } => {
394                if parsed_requires_dedicated_sql_aggregate_lane(parsed) {
395                    return Err(QueryError::unsupported_query(
396                        unsupported_sql_aggregate_lane_message(
397                            SqlAggregateSurface::GeneratedQuerySurface,
398                        ),
399                    ));
400                }
401
402                if let Some(plan) =
403                    computed_projection::computed_sql_projection_plan(&parsed.statement)?
404                {
405                    return self
406                        .execute_computed_sql_projection_dispatch_for_authority(plan, authority);
407                }
408
409                let lowered = parsed.lower_query_lane_for_entity(
410                    authority.model().name(),
411                    authority.model().primary_key.name,
412                )?;
413
414                Self::ensure_lowered_sql_query_grouping(
415                    &lowered,
416                    SqlGroupingSurface::GeneratedQuerySurface,
417                )?;
418
419                self.execute_lowered_sql_dispatch_query_for_authority(&lowered, authority)
420            }
421            SqlStatementRoute::Explain { .. } => {
422                if let Some((mode, plan)) =
423                    computed_projection::computed_sql_projection_explain_plan(&parsed.statement)?
424                {
425                    return Self::explain_computed_sql_projection_dispatch_for_authority(
426                        mode, plan, authority,
427                    )
428                    .map(SqlDispatchResult::Explain);
429                }
430
431                let lowered = parsed.lower_query_lane_for_entity(
432                    authority.model().name(),
433                    authority.model().primary_key.name,
434                )?;
435
436                lowered
437                    .explain_for_model(authority.model())
438                    .map(SqlDispatchResult::Explain)
439            }
440            SqlStatementRoute::Describe { .. }
441            | SqlStatementRoute::ShowIndexes { .. }
442            | SqlStatementRoute::ShowColumns { .. }
443            | SqlStatementRoute::ShowEntities => Err(QueryError::unsupported_query(
444                "generated SQL query surface requires query or EXPLAIN statement lanes",
445            )),
446        }
447    }
448
449    /// Execute one raw SQL string through the generated canister query surface.
450    ///
451    /// This hidden helper keeps parse, route, authority, and metadata/query
452    /// dispatch ownership in core so the build-generated `sql_dispatch` shim
453    /// stays close to a pure descriptor table plus public ABI wrapper.
454    #[doc(hidden)]
455    #[must_use]
456    pub fn execute_generated_query_surface_sql(
457        &self,
458        sql: &str,
459        authorities: &[EntityAuthority],
460    ) -> GeneratedSqlDispatchAttempt {
461        // Phase 1: normalize and parse once so every generated route family
462        // shares the same SQL ownership boundary.
463        let sql_trimmed = match trim_generated_query_sql_input(sql) {
464            Ok(sql_trimmed) => sql_trimmed,
465            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
466        };
467        let parsed = match self.parse_sql_statement(sql_trimmed) {
468            Ok(parsed) => parsed,
469            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
470        };
471
472        // Phase 2: keep SHOW ENTITIES descriptor-owned and resolve all other
473        // generated routes against the emitted authority table exactly once.
474        if matches!(parsed.route(), SqlStatementRoute::ShowEntities) {
475            return GeneratedSqlDispatchAttempt::new(
476                "",
477                None,
478                Ok(SqlDispatchResult::ShowEntities(generated_sql_entities(
479                    authorities,
480                ))),
481            );
482        }
483        let authority = match authority_for_generated_sql_route(parsed.route(), authorities) {
484            Ok(authority) => authority,
485            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
486        };
487
488        // Phase 3: dispatch the resolved route through the existing query,
489        // explain, and metadata helpers without rebuilding route ownership in
490        // the generated build output.
491        let entity_name = authority.model().name();
492        let explain_order_field = parsed
493            .route()
494            .is_explain()
495            .then_some(authority.model().primary_key.name);
496        let result = match parsed.route() {
497            SqlStatementRoute::Query { .. } | SqlStatementRoute::Explain { .. } => {
498                self.execute_generated_query_surface_dispatch_for_authority(&parsed, authority)
499            }
500            SqlStatementRoute::Describe { .. } => Ok(SqlDispatchResult::Describe(
501                self.describe_entity_model(authority.model()),
502            )),
503            SqlStatementRoute::ShowIndexes { .. } => Ok(SqlDispatchResult::ShowIndexes(
504                self.show_indexes_for_model(authority.model()),
505            )),
506            SqlStatementRoute::ShowColumns { .. } => Ok(SqlDispatchResult::ShowColumns(
507                self.show_columns_for_model(authority.model()),
508            )),
509            SqlStatementRoute::ShowEntities => unreachable!(
510                "SHOW ENTITIES is handled before authority resolution for generated query dispatch"
511            ),
512        };
513
514        GeneratedSqlDispatchAttempt::new(entity_name, explain_order_field, result)
515    }
516}