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