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 visible_indexes =
185            self.visible_indexes_for_store_model(authority.store_path(), authority.model())?;
186        let plan = query.build_plan_with_visible_indexes(&visible_indexes)?;
187        let projection = plan.projection_spec(authority.model());
188        let columns = projection_labels_from_projection_spec(&projection);
189
190        // Phase 2: execute the shared structural load path with the already
191        // derived projection semantics.
192        let projected =
193            execute_sql_projection_rows_for_canister(&self.db, self.debug, authority, plan)
194                .map_err(QueryError::execute)?;
195        let (rows, row_count) = projected.into_parts();
196
197        Ok(SqlProjectionPayload::new(columns, rows, row_count))
198    }
199
200    // Execute one structural SQL load query and return render-ready text rows
201    // for the dispatch lane when the terminal short path can prove them
202    // directly.
203    fn execute_structural_sql_projection_text(
204        &self,
205        query: StructuralQuery,
206        authority: EntityAuthority,
207    ) -> Result<SqlDispatchResult, QueryError> {
208        // Phase 1: build the structural access plan once and reuse its
209        // projection contract for both labels and text-row materialization.
210        let visible_indexes =
211            self.visible_indexes_for_store_model(authority.store_path(), authority.model())?;
212        let plan = query.build_plan_with_visible_indexes(&visible_indexes)?;
213        let projection = plan.projection_spec(authority.model());
214        let columns = projection_labels_from_projection_spec(&projection);
215
216        // Phase 2: execute the shared structural load path with the already
217        // derived projection semantics while preferring rendered SQL rows.
218        let projected =
219            execute_sql_projection_text_rows_for_canister(&self.db, self.debug, authority, plan)
220                .map_err(QueryError::execute)?;
221        let (rows, row_count) = projected.into_parts();
222
223        Ok(SqlDispatchResult::ProjectionText {
224            columns,
225            rows,
226            row_count,
227        })
228    }
229
230    // Execute one typed SQL delete query while keeping the row payload on the
231    // typed delete executor boundary that still owns non-runtime-hook delete
232    // commit-window application.
233    fn execute_typed_sql_delete<E>(&self, query: &Query<E>) -> Result<SqlDispatchResult, QueryError>
234    where
235        E: PersistedRow<Canister = C> + EntityValue,
236    {
237        let plan = self
238            .compile_query_with_visible_indexes(query)?
239            .into_executable();
240        let deleted = self
241            .with_metrics(|| self.delete_executor::<E>().execute_sql_projection(plan))
242            .map_err(QueryError::execute)?;
243        let (rows, row_count) = deleted.into_parts();
244        let rows = sql_projection_rows_from_kernel_rows(rows);
245
246        Ok(SqlProjectionPayload::new(
247            projection_labels_from_entity_model(E::MODEL),
248            rows,
249            row_count,
250        )
251        .into_dispatch_result())
252    }
253
254    // Validate that one SQL-derived query intent matches the grouped/scalar
255    // execution surface that is about to consume it.
256    pub(in crate::db::session::sql) fn ensure_sql_query_grouping<E>(
257        query: &Query<E>,
258        surface: SqlGroupingSurface,
259    ) -> Result<(), QueryError>
260    where
261        E: EntityKind,
262    {
263        match (surface, query.has_grouping()) {
264            (
265                SqlGroupingSurface::Scalar
266                | SqlGroupingSurface::Dispatch
267                | SqlGroupingSurface::GeneratedQuerySurface,
268                false,
269            )
270            | (SqlGroupingSurface::Grouped, true) => Ok(()),
271            (
272                SqlGroupingSurface::Scalar
273                | SqlGroupingSurface::Dispatch
274                | SqlGroupingSurface::GeneratedQuerySurface,
275                true,
276            )
277            | (SqlGroupingSurface::Grouped, false) => Err(QueryError::unsupported_query(
278                unsupported_sql_grouping_message(surface),
279            )),
280        }
281    }
282
283    // Validate one lowered shared SQL query shape against the grouped/scalar
284    // contract for surfaces that do not materialize a typed `Query<E>`.
285    pub(in crate::db::session::sql) fn ensure_lowered_sql_query_grouping(
286        lowered: &LoweredSqlCommand,
287        surface: SqlGroupingSurface,
288    ) -> Result<(), QueryError> {
289        let Some(query) = lowered.query() else {
290            return Ok(());
291        };
292
293        match (surface, query.has_grouping()) {
294            (
295                SqlGroupingSurface::Scalar
296                | SqlGroupingSurface::Dispatch
297                | SqlGroupingSurface::GeneratedQuerySurface,
298                false,
299            )
300            | (SqlGroupingSurface::Grouped, true) => Ok(()),
301            (
302                SqlGroupingSurface::Scalar
303                | SqlGroupingSurface::Dispatch
304                | SqlGroupingSurface::GeneratedQuerySurface,
305                true,
306            )
307            | (SqlGroupingSurface::Grouped, false) => Err(QueryError::unsupported_query(
308                unsupported_sql_grouping_message(surface),
309            )),
310        }
311    }
312
313    /// Execute one reduced SQL statement into one unified SQL dispatch payload.
314    pub fn execute_sql_dispatch<E>(&self, sql: &str) -> Result<SqlDispatchResult, QueryError>
315    where
316        E: PersistedRow<Canister = C> + EntityValue,
317    {
318        let parsed = self.parse_sql_statement(sql)?;
319
320        self.execute_sql_dispatch_parsed::<E>(&parsed)
321    }
322
323    /// Execute one parsed reduced SQL statement into one unified SQL payload.
324    pub fn execute_sql_dispatch_parsed<E>(
325        &self,
326        parsed: &SqlParsedStatement,
327    ) -> Result<SqlDispatchResult, QueryError>
328    where
329        E: PersistedRow<Canister = C> + EntityValue,
330    {
331        match parsed.route() {
332            SqlStatementRoute::Query { .. } => {
333                if parsed_requires_dedicated_sql_aggregate_lane(parsed) {
334                    return Err(QueryError::unsupported_query(
335                        unsupported_sql_aggregate_lane_message(
336                            SqlAggregateSurface::ExecuteSqlDispatch,
337                        ),
338                    ));
339                }
340
341                if let Some(plan) =
342                    computed_projection::computed_sql_projection_plan(&parsed.statement)?
343                {
344                    return self.execute_computed_sql_projection_dispatch::<E>(plan);
345                }
346
347                // Phase 1: keep typed dispatch on the shared lowered query lane
348                // for plain `SELECT`, and only pay typed query binding on the
349                // `DELETE` branch that still owns typed commit semantics.
350                let lowered = parsed
351                    .lower_query_lane_for_entity(E::MODEL.name(), E::MODEL.primary_key.name)?;
352
353                Self::ensure_lowered_sql_query_grouping(&lowered, SqlGroupingSurface::Dispatch)?;
354
355                // Phase 2: dispatch `SELECT` directly from the lowered shape so
356                // typed SQL projection does not rebuild and discard a typed
357                // `Query<E>` before returning to the structural executor path.
358                match lowered.into_query() {
359                    Some(LoweredSqlQuery::Select(select)) => self
360                        .execute_lowered_sql_dispatch_select_core(
361                            select,
362                            EntityAuthority::for_type::<E>(),
363                        ),
364                    Some(LoweredSqlQuery::Delete(delete)) => {
365                        let typed_query = bind_lowered_sql_query::<E>(
366                            LoweredSqlQuery::Delete(delete),
367                            MissingRowPolicy::Ignore,
368                        )
369                        .map_err(QueryError::from_sql_lowering_error)?;
370
371                        self.execute_typed_sql_delete(&typed_query)
372                    }
373                    None => Err(QueryError::unsupported_query(
374                        "execute_sql_dispatch accepts SELECT or DELETE only",
375                    )),
376                }
377            }
378            SqlStatementRoute::Explain { .. } => {
379                if let Some((mode, plan)) =
380                    computed_projection::computed_sql_projection_explain_plan(&parsed.statement)?
381                {
382                    return self
383                        .explain_computed_sql_projection_dispatch::<E>(mode, plan)
384                        .map(SqlDispatchResult::Explain);
385                }
386
387                let lowered = lower_sql_command_from_prepared_statement(
388                    parsed.prepare(E::MODEL.name())?,
389                    E::MODEL.primary_key.name,
390                )
391                .map_err(QueryError::from_sql_lowering_error)?;
392                if let Some(explain) = self.explain_lowered_sql_execution_for_authority(
393                    &lowered,
394                    EntityAuthority::for_type::<E>(),
395                )? {
396                    return Ok(SqlDispatchResult::Explain(explain));
397                }
398
399                self.explain_lowered_sql_for_authority(&lowered, EntityAuthority::for_type::<E>())
400                    .map(SqlDispatchResult::Explain)
401            }
402            SqlStatementRoute::Describe { .. } => {
403                Ok(SqlDispatchResult::Describe(self.describe_entity::<E>()))
404            }
405            SqlStatementRoute::ShowIndexes { .. } => {
406                Ok(SqlDispatchResult::ShowIndexes(self.show_indexes::<E>()))
407            }
408            SqlStatementRoute::ShowColumns { .. } => {
409                Ok(SqlDispatchResult::ShowColumns(self.show_columns::<E>()))
410            }
411            SqlStatementRoute::ShowEntities => {
412                Ok(SqlDispatchResult::ShowEntities(self.show_entities()))
413            }
414        }
415    }
416
417    /// Execute one parsed reduced SQL statement through the generated canister
418    /// query/explain surface for one already-resolved dynamic authority.
419    ///
420    /// This keeps the canister SQL facade on the same reduced SQL ownership
421    /// boundary as typed dispatch without forcing the outer facade to reopen
422    /// typed-generic routing just to preserve parity for computed projections.
423    #[doc(hidden)]
424    pub fn execute_generated_query_surface_dispatch_for_authority(
425        &self,
426        parsed: &SqlParsedStatement,
427        authority: EntityAuthority,
428    ) -> Result<SqlDispatchResult, QueryError> {
429        match parsed.route() {
430            SqlStatementRoute::Query { .. } => {
431                if parsed_requires_dedicated_sql_aggregate_lane(parsed) {
432                    return Err(QueryError::unsupported_query(
433                        unsupported_sql_aggregate_lane_message(
434                            SqlAggregateSurface::GeneratedQuerySurface,
435                        ),
436                    ));
437                }
438
439                if let Some(plan) =
440                    computed_projection::computed_sql_projection_plan(&parsed.statement)?
441                {
442                    return self
443                        .execute_computed_sql_projection_dispatch_for_authority(plan, authority);
444                }
445
446                let lowered = parsed.lower_query_lane_for_entity(
447                    authority.model().name(),
448                    authority.model().primary_key.name,
449                )?;
450
451                Self::ensure_lowered_sql_query_grouping(
452                    &lowered,
453                    SqlGroupingSurface::GeneratedQuerySurface,
454                )?;
455
456                self.execute_lowered_sql_dispatch_query_for_authority(lowered, authority)
457            }
458            SqlStatementRoute::Explain { .. } => {
459                if let Some((mode, plan)) =
460                    computed_projection::computed_sql_projection_explain_plan(&parsed.statement)?
461                {
462                    return self
463                        .explain_computed_sql_projection_dispatch_for_authority(
464                            mode, plan, authority,
465                        )
466                        .map(SqlDispatchResult::Explain);
467                }
468
469                let lowered = parsed.lower_query_lane_for_entity(
470                    authority.model().name(),
471                    authority.model().primary_key.name,
472                )?;
473                if let Some(explain) =
474                    self.explain_lowered_sql_execution_for_authority(&lowered, authority)?
475                {
476                    return Ok(SqlDispatchResult::Explain(explain));
477                }
478
479                self.explain_lowered_sql_for_authority(&lowered, authority)
480                    .map(SqlDispatchResult::Explain)
481            }
482            SqlStatementRoute::Describe { .. }
483            | SqlStatementRoute::ShowIndexes { .. }
484            | SqlStatementRoute::ShowColumns { .. }
485            | SqlStatementRoute::ShowEntities => Err(QueryError::unsupported_query(
486                "generated SQL query surface requires query or EXPLAIN statement lanes",
487            )),
488        }
489    }
490
491    /// Execute one raw SQL string through the generated canister query surface.
492    ///
493    /// This hidden helper keeps parse, route, authority, and metadata/query
494    /// dispatch ownership in core so the build-generated `sql_dispatch` shim
495    /// stays close to a pure descriptor table plus public ABI wrapper.
496    #[doc(hidden)]
497    #[must_use]
498    pub fn execute_generated_query_surface_sql(
499        &self,
500        sql: &str,
501        authorities: &[EntityAuthority],
502    ) -> GeneratedSqlDispatchAttempt {
503        // Phase 1: normalize and parse once so every generated route family
504        // shares the same SQL ownership boundary.
505        let sql_trimmed = match trim_generated_query_sql_input(sql) {
506            Ok(sql_trimmed) => sql_trimmed,
507            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
508        };
509        let parsed = match self.parse_sql_statement(sql_trimmed) {
510            Ok(parsed) => parsed,
511            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
512        };
513
514        // Phase 2: keep SHOW ENTITIES descriptor-owned and resolve all other
515        // generated routes against the emitted authority table exactly once.
516        if matches!(parsed.route(), SqlStatementRoute::ShowEntities) {
517            return GeneratedSqlDispatchAttempt::new(
518                "",
519                None,
520                Ok(SqlDispatchResult::ShowEntities(generated_sql_entities(
521                    authorities,
522                ))),
523            );
524        }
525        let authority = match authority_for_generated_sql_route(parsed.route(), authorities) {
526            Ok(authority) => authority,
527            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
528        };
529
530        // Phase 3: dispatch the resolved route through the existing query,
531        // explain, and metadata helpers without rebuilding route ownership in
532        // the generated build output.
533        let entity_name = authority.model().name();
534        let explain_order_field = parsed
535            .route()
536            .is_explain()
537            .then_some(authority.model().primary_key.name);
538        let result = match parsed.route() {
539            SqlStatementRoute::Query { .. } | SqlStatementRoute::Explain { .. } => {
540                self.execute_generated_query_surface_dispatch_for_authority(&parsed, authority)
541            }
542            SqlStatementRoute::Describe { .. } => Ok(SqlDispatchResult::Describe(
543                self.describe_entity_model(authority.model()),
544            )),
545            SqlStatementRoute::ShowIndexes { .. } => Ok(SqlDispatchResult::ShowIndexes(
546                self.show_indexes_for_store_model(authority.store_path(), authority.model()),
547            )),
548            SqlStatementRoute::ShowColumns { .. } => Ok(SqlDispatchResult::ShowColumns(
549                self.show_columns_for_model(authority.model()),
550            )),
551            SqlStatementRoute::ShowEntities => unreachable!(
552                "SHOW ENTITIES is handled before authority resolution for generated query dispatch"
553            ),
554        };
555
556        GeneratedSqlDispatchAttempt::new(entity_name, explain_order_field, result)
557    }
558}